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({