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
61 changes: 43 additions & 18 deletions packages/integrations/src/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Concatenate adjacent-word buffers only once

When an incoming Gmail Subject or From contains thousands of adjacent same-charset encoded words, this loop copies the entire accumulated prefix for every word, making decodeHeader quadratic in header size. The mapMessage path accepts unrestricted header strings up to the whole-response limit, so a sufficiently large folded header can cause seconds of CPU time and heavy allocation churn whenever the message is listed or opened; collect the byte chunks and call Buffer.concat once in flush() instead.

Useful? React with 👍 / 👎.

pendingOriginal += original;
} else {
flush();
pending = bytes;
pendingCharset = normalized;
pendingOriginal = original;
}
}
flush();
return decoded;
});
}
function decodeSnippet(value: string): string {
const entities: Record<string, string> = {
Expand Down
29 changes: 29 additions & 0 deletions tests/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading