Search code examples
javascripthttpfetchmultipartform-datagmail-api

Javascript fetch fails to read Multipart response from GMail API Batch request


I need to use GMail API to retrieve multiple emails data so I am using Batch API. I could finally craft a good enough request but the problem is Javascript doesn't seem to properly parse the response. Note that this is pure browser Javascript, I am not using any server.

Please refer the code below. The request/response was good upon inspection, but at the line where I call r.formData() method, I receive this error with no further explanation:

TypeError: Failed to fetch

    async getGmailMessageMetadatasAsync(ids: string[], token: string): Promise<IGmailMetaData[]> {
        if (!ids.length) {
            return [];
        }

        const url = `https://gmail.googleapis.com/batch/gmail/v1`;

        const body = new FormData();
        for (let id of ids) {
            const blobContent = `GET /gmail/v1/users/me/messages/${encodeURI(id)}?format=METADATA`;
            const blob = new Blob([blobContent], {
                type: "application/http",
            });

            body.append("dummy", blob);
        }

        const r = await fetch(url, {
            body: body,
            method: "POST",
            headers: this.getAuthHeader(token),
        });

        if (!r.ok) {
            throw r;
        }

        try {
            const content = await r.formData(); // This won't work

            debugger;
            for (let key of content) {

            }
        } catch (e) {
            console.error(e);
            debugger;
        }
        

        return <any>[];
    }

If I replace r.formData() with r.text(), it works but then I have to parse the text by myself which I don't think is good. The response has correct content-type: multipart/form-data; boundary=batch_HViQtsA3Z_aYrPoOlukRFgkPEUDoDh23 and the body looks like this:

"
--batch_HViQtsA3Z_aYrPoOlukRFgkPEUDoDh23
Content-Type: application/http
Content-ID: response-

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Vary: Origin
Vary: X-Origin
Vary: Referer

{
  "id": "1778c9cc9345a9f4",
  "threadId": "1778c9cc9345a9f4",
  "labelIds": [
    "IMPORTANT",
    "CATEGORY_PERSONAL",
    "INBOX"
  ],

<More content>

How do I properly parse this response and get the JSON content of each email?


Solution

  • Thanks to the other answer, I realized the response body is multipart/mixed, not multipart/form-data so I wrote this parser myself.

    
    export class MultipartMixedService {
    
        static async parseAsync(r: Response): Promise<MultipartMixedEntry[]> {
            const text = await r.text();
            const contentType = r.headers.get("Content-Type");
    
            return this.parse(text, contentType);
        }
    
        static parse(body: string, contentType: string): MultipartMixedEntry[] {
            const result: MultipartMixedEntry[] = [];
    
            const contentTypeData = this.parseHeader(contentType);
            const boundary = contentTypeData.directives.get("boundary");
            if (!boundary) {
                throw new Error("Invalid Content Type: no boundary");
            }
            const boundaryText = "--" + boundary;
    
            let line: string;
            let pos = -1;
            let currEntry: MultipartMixedEntry = null;
            let parsingEntryHeaders = false;
            let parsingBodyHeaders = false;
            let parsingBodyFirstLine = false;
    
            do {
                [line, pos] = this.nextLine(body, pos);
    
                if (line.length == 0 || line == "\r") { // Empty Line
                    if (parsingEntryHeaders) {
                        // Start parsing Body Headers
                        parsingEntryHeaders = false;
                        parsingBodyHeaders = true;
                    } else if (parsingBodyHeaders) {
                        // Start parsing body
                        parsingBodyHeaders = false;
                        parsingBodyFirstLine = true;
                    } else if (currEntry != null) {
                        // Empty line in body, just add it
                        currEntry.body += (parsingBodyFirstLine ? "" : "\n") + "\n";
                        parsingBodyFirstLine = false;
                    }
    
                    // Else, it's just empty starting lines
                } else if (line.startsWith(boundaryText)) {
                    // Remove one extra line from the body
                    if (currEntry != null) {
                        currEntry.body = currEntry.body.substring(0, currEntry.body.length - 1);
                    }
    
                    // Check if it is the end
                    if (line.endsWith("--")) {
                        return result;
                    }
    
                    // If not, it's the start of new entry
                    currEntry = new MultipartMixedEntry();
                    result.push(currEntry);
                    parsingEntryHeaders = true;
                } else {
                    if (!currEntry) {
                        // Trash content
                        throw new Error("Error parsing response: Unexpected data.");
                    }
    
                    // Add content
                    if (parsingEntryHeaders || parsingBodyHeaders) {
                        // Headers
                        const headers = parsingEntryHeaders ? currEntry.entryHeaders : currEntry.bodyHeaders;
                        const headerParts = line.split(":", 2);
    
                        if (headerParts.length == 1) {
                            headers.append("X-Extra", headerParts[0].trim());
                        } else {
                            headers.append(headerParts[0]?.trim(), headerParts[1].trim());
                        }
                        
                    } else {
                        // Body
                        currEntry.body += (parsingBodyFirstLine ? "" : "\n") + line;
                        parsingBodyFirstLine = false;
                    }
                }
            } while (pos > -1);
    
            return result;
        }
    
        static parseHeader(headerValue: string): HeaderData {
            if (!headerValue) {
                throw new Error("Invalid Header Value: " + headerValue);
            }
    
            var result = new HeaderData();
            result.fullText = headerValue;
    
            const parts = headerValue.split(/;/g);
            result.value = parts[0];
    
            for (var i = 1; i < parts.length; i++) {
                const part = parts[i].trim();
                const partData = part.split("=", 2);
    
                result.directives.append(partData[0], partData[1]);
            }
    
            return result;
        }
    
        private static nextLine(text: string, lastPos: number): [string, number] {
            const nextLinePos = text.indexOf("\n", lastPos + 1);
    
            let line = text.substring(lastPos + 1, nextLinePos == -1 ? null : nextLinePos);
            while (line.endsWith("\r")) {
                line = line.substr(0, line.length - 1);
            }
    
            return [line, nextLinePos];
        }
    
    }
    
    export class MultipartMixedEntry {
    
        entryHeaders: Headers = new Headers();
        bodyHeaders: Headers = new Headers();
    
        body: string = "";
    
        json<T = any>(): T {
            return JSON.parse(this.body);
        }
    
    }
    
    export class HeaderData {
    
        fullText: string;
        value: string;
        directives: Headers = new Headers();
    
    }
    

    Usage:

            const r = await fetch(url, {
                body: body,
                method: "POST",
                headers: headers,
            });
    
            if (!r.ok) {
                throw r;
            }
    
            try {
                const contentData = await MultipartMixedService.parseAsync(r);
    
                // Other code
    

    Someone asked for request and response body, here is an example (I censored the Bearer token and my email):

    fetch("https://gmail.googleapis.com/batch/gmail/v1", {
      "headers": {
        "accept": "*/*",
        "accept-language": "en-US,en;q=0.9,vi;q=0.8,fr;q=0.7",
        "authorization": "Bearer <YOUR TOKEN>",
        "content-type": "multipart/form-data; boundary=----WebKitFormBoundaryZ9nvH6zUTGoR7aAs",
        "sec-ch-ua": "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"90\", \"Microsoft Edge\";v=\"90\"",
        "sec-ch-ua-mobile": "?0",
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "cross-site"
      },
      "referrerPolicy": "strict-origin-when-cross-origin",
      "body": "------WebKitFormBoundaryZ9nvH6zUTGoR7aAs\r\nContent-Disposition: form-data; name=\"dummy\"; filename=\"blob\"\r\nContent-Type: application/http\r\n\r\nGET /gmail/v1/users/me/messages/1799c0f9031dc75a?format=METADATA\r\n------WebKitFormBoundaryZ9nvH6zUTGoR7aAs--\r\n",
      "method": "POST",
      "mode": "cors",
      "credentials": "include"
    });
    
    --batch_jb1MbufS6_fEEIu5e6taSCLa9ZOYifdP
    Content-Type: application/http
    Content-ID: response-
    
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=UTF-8
    Vary: Origin
    Vary: X-Origin
    Vary: Referer
    
    {
      "id": "1799c0f9031dc75a",
      "threadId": "1799c0f9031dc75a",
      "labelIds": [
        "UNREAD",
        "SENT",
        "INBOX"
      ],
      "payload": {
        "partId": "",
        "headers": [
          {
            "name": "Return-Path",
            "value": "\u003c******@gmail.com\u003e"
          },
          {
            "name": "Received",
            "value": "from LukePC ([****:***:****:****:d906:d8c4:10f6:6146])        by smtp.gmail.com with ESMTPSA id u6sm8286689pjy.51.2021.05.23.18.48.55        for \u003c******@gmail.com\u003e        (version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256 bits=128/128);        Sun, 23 May 2021 18:48:56 -0700 (PDT)"
          },
          {
            "name": "From",
            "value": "\u003c******@gmail.com\u003e"
          },
          {
            "name": "To",
            "value": "\u003c******@gmail.com\u003e"
          },
          {
            "name": "Subject",
            "value": "Test Email"
          },
          {
            "name": "Date",
            "value": "Mon, 24 May 2021 08:48:52 +0700"
          },
          {
            "name": "Message-ID",
            "value": "\[email protected]\u003e"
          },
          {
            "name": "MIME-Version",
            "value": "1.0"
          },
          {
            "name": "Content-Type",
            "value": "multipart/alternative; boundary=\"----=_NextPart_000_0003_01D75079.A089F6A0\""
          },
          {
            "name": "X-Mailer",
            "value": "Microsoft Outlook 16.0"
          },
          {
            "name": "Thread-Index",
            "value": "AddQPvAK354ufYfSQqqfwTDwp7zDCQ=="
          },
          {
            "name": "Content-Language",
            "value": "en-us"
          }
        ]
      },
      "sizeEstimate": 2750,
      "historyId": "197435",
      "internalDate": "1621820932000"
    }
    
    --batch_jb1MbufS6_fEEIu5e6taSCLa9ZOYifdP--