Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 56 additions & 18 deletions packages/integrations/src/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,27 +149,65 @@ 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) => {
// Valid words keep independent decoder boundaries (including their BOMs).
// Tolerate split UTF-8 only when the words cannot decode independently.
const word = /=\?([^?]+)\?([bq])\?([^?]*)\?=/gi;
const decoded: string[] = [];
const utf8 = new TextDecoder("utf-8", { fatal: true });
let pending: Buffer[] = [];
let pendingFallback: string[] = [];
const flush = () => {
if (pending.length === 0) 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);
// One concatenate per malformed group, never one per incoming word.
decoded.push(utf8.decode(Buffer.concat(pending)));
} catch {
return original;
for (const text of pendingFallback) decoded.push(text);
}
},
);
pending = [];
pendingFallback = [];
};
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",
);
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();
decoded.push(decoder.decode(bytes));
}
}
flush();
return decoded.join("");
});
}
function decodeSnippet(value: string): string {
const entities: Record<string, string> = {
Expand Down
99 changes: 98 additions & 1 deletion tests/google.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,6 +16,17 @@ function clientWith(handler: (request: Request) => Response | Promise<Response>)
});
}
const json = (data: unknown, status = 200) => Response.json(data, { status });
async function subjectFromHeader(value: string): Promise<string> {
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"],
Expand Down Expand Up @@ -121,6 +132,92 @@ test("mail decodes folded adjacent encoded words without inserting header whites
}
});

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;
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("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({
Expand Down
Loading