From 8b24bc9b753227cf2fc875225be7475f51dbe66d Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:45:12 +0530 Subject: [PATCH 1/2] fix: join adjacent MIME encoded words before decoding multibyte text Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- packages/integrations/src/google.ts | 61 ++++++++++++++++++++--------- tests/google.test.ts | 29 ++++++++++++++ 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/integrations/src/google.ts b/packages/integrations/src/google.ts index c94e84256..8ec74ca8b 100644 --- a/packages/integrations/src/google.ts +++ b/packages/integrations/src/google.ts @@ -149,27 +149,52 @@ function decodeHeader(value: string): string { // RFC 2047: ignore linear whitespace, including folding, between encoded words. return value .replace(/(\?=)(?:[ \t]|\r\n[ \t])+(?==\?)/g, "$1") - .replace( - /=\?([^?]+)\?([bq])\?([^?]*)\?=/gi, - (original, charset: string, encoding: string, text: string) => { + .replace(/(?:=\?[^?]+\?[bq]\?[^?]*\?=)+/gi, (run) => { + // §6.2: adjacent encoded words concatenate before decoding, so a header + // wrapped at the encoded-word length limit can split a multibyte + // character across words; join same-charset bytes before decoding. + const word = /=\?([^?]+)\?([bq])\?([^?]*)\?=/gi; + let decoded = ""; + let pending: Buffer | null = null; + let pendingCharset = ""; + let pendingOriginal = ""; + const flush = () => { + if (pending === null) return; try { - const bytes = - encoding.toLowerCase() === "b" - ? Buffer.from(text, "base64") - : Buffer.from( - text - .replace(/_/g, " ") - .replace(/=([0-9a-f]{2})/gi, (_, code: string) => - String.fromCharCode(Number.parseInt(code, 16)), - ), - "latin1", - ); - return new TextDecoder(charset.split("*")[0]).decode(bytes); + decoded += new TextDecoder(pendingCharset).decode(pending); } catch { - return original; + decoded += pendingOriginal; } - }, - ); + pending = null; + pendingOriginal = ""; + }; + for (const match of run.matchAll(word)) { + const [original, charset, encoding, text] = match; + const normalized = charset.split("*")[0].toLowerCase(); + const bytes = + encoding.toLowerCase() === "b" + ? Buffer.from(text, "base64") + : Buffer.from( + text + .replace(/_/g, " ") + .replace(/=([0-9a-f]{2})/gi, (_, code: string) => + String.fromCharCode(Number.parseInt(code, 16)), + ), + "latin1", + ); + if (pending !== null && normalized === pendingCharset) { + pending = Buffer.concat([pending, bytes]); + pendingOriginal += original; + } else { + flush(); + pending = bytes; + pendingCharset = normalized; + pendingOriginal = original; + } + } + flush(); + return decoded; + }); } function decodeSnippet(value: string): string { const entities: Record = { diff --git a/tests/google.test.ts b/tests/google.test.ts index c5e607b23..5b48a5e02 100644 --- a/tests/google.test.ts +++ b/tests/google.test.ts @@ -121,6 +121,35 @@ test("mail decodes folded adjacent encoded words without inserting header whites } }); +test("mail joins adjacent encoded words that split a multibyte character", async () => { + // RFC 2047 §6.2: adjacent encoded words are concatenated before decoding, so a + // sender that wraps a long header at the encoded-word length limit may break a + // multibyte character across two words. "Visit résumé" split inside each é. + const word = (bytes: number[]) => `=?UTF-8?B?${Buffer.from(bytes).toString("base64")}?=`; + const visitResume = [..."Visit résumé"].flatMap((char) => [...Buffer.from(char)]); + const splitAt = visitResume.indexOf(0xc3) + 1; + const subjects = [ + `${word(visitResume.slice(0, splitAt))} ${word(visitResume.slice(splitAt))}`, + `${word(visitResume.slice(0, splitAt))}\r\n ${word(visitResume.slice(splitAt))}`, + "=?UTF-8?Q?Visit_r=C3?= =?UTF-8?Q?=A9sum=C3=A9?=", + ]; + for (const value of subjects) { + const client = clientWith(() => + json({ + id: "thread1", + messages: [ + { + id: "msg1", + threadId: "thread1", + payload: { headers: [{ name: "Subject", value }] }, + }, + ], + }), + ); + assert.equal((await client.getThread("thread1"))[0].subject, "Visit résumé", value); + } +}); + test("header decoding preserves spaces encoded inside words and next to plain text", async () => { const client = clientWith(() => json({ From c11d9e32d40d8584fc7c3668481ab22ffd2f7691 Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:13:46 +0530 Subject: [PATCH 2/2] Preserve independent MIME words and bound split UTF-8 header copying --- packages/integrations/src/google.ts | 51 ++++++++++++------- tests/google.test.ts | 78 +++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 24 deletions(-) diff --git a/packages/integrations/src/google.ts b/packages/integrations/src/google.ts index 8ec74ca8b..4fb1a89bc 100644 --- a/packages/integrations/src/google.ts +++ b/packages/integrations/src/google.ts @@ -150,23 +150,23 @@ function decodeHeader(value: string): string { return value .replace(/(\?=)(?:[ \t]|\r\n[ \t])+(?==\?)/g, "$1") .replace(/(?:=\?[^?]+\?[bq]\?[^?]*\?=)+/gi, (run) => { - // §6.2: adjacent encoded words concatenate before decoding, so a header - // wrapped at the encoded-word length limit can split a multibyte - // character across words; join same-charset bytes before decoding. + // Valid words keep independent decoder boundaries (including their BOMs). + // Tolerate split UTF-8 only when the words cannot decode independently. const word = /=\?([^?]+)\?([bq])\?([^?]*)\?=/gi; - let decoded = ""; - let pending: Buffer | null = null; - let pendingCharset = ""; - let pendingOriginal = ""; + const decoded: string[] = []; + const utf8 = new TextDecoder("utf-8", { fatal: true }); + let pending: Buffer[] = []; + let pendingFallback: string[] = []; const flush = () => { - if (pending === null) return; + if (pending.length === 0) return; try { - decoded += new TextDecoder(pendingCharset).decode(pending); + // One concatenate per malformed group, never one per incoming word. + decoded.push(utf8.decode(Buffer.concat(pending))); } catch { - decoded += pendingOriginal; + for (const text of pendingFallback) decoded.push(text); } - pending = null; - pendingOriginal = ""; + pending = []; + pendingFallback = []; }; for (const match of run.matchAll(word)) { const [original, charset, encoding, text] = match; @@ -182,18 +182,31 @@ function decodeHeader(value: string): string { ), "latin1", ); - if (pending !== null && normalized === pendingCharset) { - pending = Buffer.concat([pending, bytes]); - pendingOriginal += original; + let decoder: TextDecoder; + try { + decoder = new TextDecoder(normalized); + } catch { + flush(); + decoded.push(original); + continue; + } + if (decoder.encoding === "utf-8") { + if (bytes.length === 0) continue; + try { + const text = utf8.decode(bytes); + flush(); + decoded.push(text); + } catch { + pending.push(bytes); + pendingFallback.push(decoder.decode(bytes)); + } } else { flush(); - pending = bytes; - pendingCharset = normalized; - pendingOriginal = original; + decoded.push(decoder.decode(bytes)); } } flush(); - return decoded; + return decoded.join(""); }); } function decodeSnippet(value: string): string { diff --git a/tests/google.test.ts b/tests/google.test.ts index 5b48a5e02..5488d06f4 100644 --- a/tests/google.test.ts +++ b/tests/google.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import test, { mock } from "node:test"; import { emailDraftSchema, eventDraftSchema } from "../packages/domain/src/index.ts"; import { GoogleApiError, @@ -16,6 +16,17 @@ function clientWith(handler: (request: Request) => Response | Promise) }); } const json = (data: unknown, status = 200) => Response.json(data, { status }); +async function subjectFromHeader(value: string): Promise { + const client = clientWith(() => + json({ + id: "thread1", + messages: [ + { id: "msg1", threadId: "thread1", payload: { headers: [{ name: "Subject", value }] } }, + ], + }), + ); + return (await client.getThread("thread1"))[0].subject; +} const email = () => emailDraftSchema.parse({ to: ["reader@example.com"], @@ -121,10 +132,9 @@ test("mail decodes folded adjacent encoded words without inserting header whites } }); -test("mail joins adjacent encoded words that split a multibyte character", async () => { - // RFC 2047 §6.2: adjacent encoded words are concatenated before decoding, so a - // sender that wraps a long header at the encoded-word length limit may break a - // multibyte character across two words. "Visit résumé" split inside each é. +test("mail tolerates malformed adjacent UTF-8 words that split a multibyte character", async () => { + // Optional tolerance for non-conforming senders: RFC 2047 requires complete + // characters in each encoded word. "Visit résumé" is split inside an é. const word = (bytes: number[]) => `=?UTF-8?B?${Buffer.from(bytes).toString("base64")}?=`; const visitResume = [..."Visit résumé"].flatMap((char) => [...Buffer.from(char)]); const splitAt = visitResume.indexOf(0xc3) + 1; @@ -150,6 +160,64 @@ test("mail joins adjacent encoded words that split a multibyte character", async } }); +test("mail preserves independent BOM-prefixed encoded words", async () => { + for (const value of [ + "=?UTF-16?B?//5BAA==?= =?UTF-16?B?//5CAA==?=", + "=?UTF-8?B?77u/QQ==?= =?UTF-8?B?77u/Qg==?=", + ]) { + assert.equal(await subjectFromHeader(value), "AB", value); + } +}); + +test("mail decodes long independent headers without repeatedly copying accumulated bytes", async () => { + const value = Array(4096).fill("=?UTF-8?B?d29yZA==?=").join(" "); + const concat = Buffer.concat; + let copied = 0; + const spy = mock.method(Buffer, "concat", (parts: readonly Uint8Array[], length?: number) => { + copied += length ?? parts.reduce((total, part) => total + part.byteLength, 0); + return concat(parts, length); + }); + try { + assert.equal(await subjectFromHeader(value), "word".repeat(4096)); + assert.ok( + copied <= Buffer.byteLength(value) * 8, + `Copied ${copied} bytes for a ${value.length}-byte header`, + ); + } finally { + spy.mock.restore(); + } +}); + +test("mail tolerates a long run of split UTF-8 bytes with bounded copying", async () => { + const value = Array(4096).fill("=?UTF-8?B?ww==?= =?UTF-8?B?qQ==?=").join(" "); + const concat = Buffer.concat; + let copied = 0; + const spy = mock.method(Buffer, "concat", (parts: readonly Uint8Array[], length?: number) => { + copied += length ?? parts.reduce((total, part) => total + part.byteLength, 0); + return concat(parts, length); + }); + try { + assert.equal(await subjectFromHeader(value), "é".repeat(4096)); + assert.ok( + copied <= Buffer.byteLength(value) * 8, + `Copied ${copied} bytes for a ${value.length}-byte header`, + ); + } finally { + spy.mock.restore(); + } +}); + +test("malformed UTF-8 does not change valid following words or cross charset boundaries", async () => { + for (const [value, expected] of [ + ["=?UTF-8?B?ww==?= =?UTF-8?B?77u/QQ==?=", "�A"], + ["=?UTF-8?B?ww==?= =?ISO-8859-1?Q?=A9?=", "�©"], + ["=?UTF-8?B?ww==?= =?unknown-charset?B?QQ==?=", "�=?unknown-charset?B?QQ==?="], + ["=?UTF-8?B?ww==?= =?utf8?B?qQ==?=", "é"], + ]) { + assert.equal(await subjectFromHeader(value), expected, value); + } +}); + test("header decoding preserves spaces encoded inside words and next to plain text", async () => { const client = clientWith(() => json({