From 9c28e27e00f3d214a5daffbf981284041d2662ae Mon Sep 17 00:00:00 2001 From: nvtoan0201-swe Date: Wed, 30 Sep 2026 11:38:31 +0700 Subject: [PATCH] fix: harden Gmail message parsing A single message could break or distort a mailbox read: an unknown charset label threw from TextDecoder, RFC 2231 language tags blocked header decoding, a quoted display name containing an address was preferred over the real sender, a bare angle-addr From produced an empty sender, and an attached message/rfc822 had its nested text merged into the parent body. Decode bodies with a UTF-8 fallback, strip language tags, ignore addresses inside quoted display names, fall back to the bare address, and stop recursing into attachment parts. Large remote bodies stored behind attachment IDs are still hydrated and read as message text. --- packages/integrations/src/google.ts | 33 ++++++++++++---- tests/google.test.ts | 58 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/integrations/src/google.ts b/packages/integrations/src/google.ts index cb21f2c54..358f2f538 100644 --- a/packages/integrations/src/google.ts +++ b/packages/integrations/src/google.ts @@ -162,13 +162,21 @@ function decodeHeader(value: string): string { ), "latin1", ); - return new TextDecoder(charset).decode(bytes); + return new TextDecoder(charset.split("*")[0]).decode(bytes); } catch { return original; } }, ); } +/** Decode a message-body part; unknown or malformed charset labels fall back to UTF-8. */ +function decodeText(bytes: Buffer, charset: string): string { + try { + return new TextDecoder(charset).decode(bytes); + } catch { + return new TextDecoder("utf-8").decode(bytes); + } +} function decodeSnippet(value: string): string { const entities: Record = { amp: "&", @@ -191,7 +199,13 @@ function decodeSnippet(value: string): string { ); } function addresses(value: string): string[] { - return value.match(/[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+/g) ?? []; + // A quoted display name may itself contain an e-mail-looking string; only + // addresses outside display names count. + return ( + value + .replace(/"(?:[^"\\]|\\.)*"\s*(?=<)/g, " ") + .match(/[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+/g) ?? [] + ); } /** Extract text from a parsed HTML tree. Nothing is rendered or fetched. */ function htmlToPlainText(html: string): string { @@ -277,12 +291,16 @@ function mapMessage(message: z.infer): Mail { const attachments: string[] = []; const visit = (part: GmailPart, depth: number) => { if (depth > 30) throw new Error("Gmail message MIME nesting exceeds the limit"); + // Attachments carry their own content (or an attached message); never merge + // their parts into the parent text. A remote body has no filename: it is + // hydrated above and still counts as message text. + const attached = Boolean(part.filename) || part.mimeType === "message/rfc822"; if (part.filename && part.body?.attachmentId) attachments.push( `${message.id}:${part.body.attachmentId}:${encodeURIComponent(part.filename)}`, ); if ( - !part.filename && + !attached && (part.mimeType === "text/plain" || part.mimeType === "text/html") && part.body?.data ) { @@ -290,18 +308,19 @@ function mapMessage(message: z.infer): Mail { headers(part) .get("content-type") ?.match(/charset=["']?([^;"'\s]+)/i)?.[1] ?? "utf-8"; - const text = new TextDecoder(charset).decode(decodeBase64url(part.body.data, 1024 * 1024)); + const text = decodeText(decodeBase64url(part.body.data, 1024 * 1024), charset); if (part.mimeType === "text/plain") plain.push(text); else html.push(htmlToPlainText(text)); } - for (const child of part.parts ?? []) visit(child, depth + 1); + if (!attached) for (const child of part.parts ?? []) visit(child, depth + 1); }; if (message.payload) visit(message.payload, 0); const from = decodeHeader(metadata.get("from") ?? ""); const address = addresses(from)[0] ?? from; - const sender = from.includes("<") + const displayName = from.includes("<") ? from.slice(0, from.indexOf("<")).trim().replace(/^"|"$/g, "") - : address; + : ""; + const sender = displayName || address; const time = message.internalDate ? Number(message.internalDate) : Date.parse(metadata.get("date") ?? ""); diff --git a/tests/google.test.ts b/tests/google.test.ts index 9b2e64569..d8fc488f1 100644 --- a/tests/google.test.ts +++ b/tests/google.test.ts @@ -758,3 +758,61 @@ test("single-event validation is repeated at execution and read failures never d await assert.rejects(failing.deleteEvent("primary", "event-1"), GoogleApiError); assert.equal(writes, 0); }); + +test("mail parsing tolerates unknown charsets, RFC 2231 words, and display names with addresses", async () => { + const client = clientWith((request) => + new URL(request.url).pathname.endsWith("/messages") + ? json({ messages: [{ id: "msg-charset" }] }) + : json({ + id: "msg-charset", + threadId: "thread-charset", + payload: { + mimeType: "text/plain", + headers: [ + { name: "From", value: "" }, + { name: "To", value: '"billing@other.example" ' }, + { name: "Subject", value: "=?UTF-8*en?B?SGVsbG8=?=" }, + { name: "Content-Type", value: "text/plain; charset=unknown-8bit" }, + ], + body: { data: base64url("Body with unknown charset ✓") }, + }, + }), + ); + const [mail] = await client.listMail(); + assert.equal(mail.body, "Body with unknown charset ✓"); + assert.equal(mail.subject, "Hello"); + assert.equal(mail.from, "bare@example.com"); + assert.equal(mail.sender, "bare@example.com"); + assert.deepEqual(mail.to, ["real@example.com"]); +}); + +test("an attached message is not merged into the parent body", async () => { + const client = clientWith((request) => + new URL(request.url).pathname.endsWith("/messages") + ? json({ messages: [{ id: "msg-forward" }] }) + : json({ + id: "msg-forward", + threadId: "thread-forward", + payload: { + mimeType: "multipart/mixed", + parts: [ + { mimeType: "text/plain", body: { data: base64url("Outer body text.") } }, + { + mimeType: "message/rfc822", + filename: "forwarded.eml", + body: { attachmentId: "attach2", size: 42 }, + parts: [ + { + mimeType: "text/plain", + body: { data: base64url("INNER ATTACHED MESSAGE BODY") }, + }, + ], + }, + ], + }, + }), + ); + const [mail] = await client.listMail(); + assert.equal(mail.body, "Outer body text."); + assert.deepEqual(mail.attachments, ["msg-forward:attach2:forwarded.eml"]); +});