From 9eea46aa21695cdf6302ff391e6e36f0226c3c48 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:22:49 +0900 Subject: [PATCH] Decode an inbound email part in the charset it names --- server/src/responsibilities/email.ts | 53 ++++++++++++++++---- server/tests/responsibility-triggers.test.ts | 23 +++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/server/src/responsibilities/email.ts b/server/src/responsibilities/email.ts index 50b139494..c810344ce 100644 --- a/server/src/responsibilities/email.ts +++ b/server/src/responsibilities/email.ts @@ -157,19 +157,46 @@ function unfoldHeaders(block: string) { } return headers; } -function decodeBody(body: string, encoding: string | undefined) { +/** + * The text a part's transfer-encoded bytes spell, in the charset its Content-Type names. + * + * Base64 and quoted-printable carry bytes, not text: a `charset=iso-8859-1` part saying "caf=E9" is + * "café", and reading those bytes as UTF-8 turned every accented letter into U+FFFD. A charset the + * runtime does not know falls back to UTF-8, as before. + */ +function decodeBytes(bytes: Buffer, charset: string | undefined) { + if (charset) { + try { + return new TextDecoder(charset).decode(bytes); + } catch { + // unknown label: read as UTF-8 + } + } + return bytes.toString("utf8"); +} +function decodeBody( + body: string, + encoding: string | undefined, + charset: string | undefined, +) { const kind = encoding?.toLowerCase(); if (kind === "base64") - return Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf8"); + return decodeBytes( + Buffer.from(body.replace(/\s+/g, ""), "base64"), + charset, + ); if (kind === "quoted-printable") - return Buffer.from( - body - .replace(/=\r?\n/g, "") - .replace(/=([0-9A-F]{2})/gi, (_, hex: string) => - String.fromCharCode(Number.parseInt(hex, 16)), - ), - "latin1", - ).toString("utf8"); + return decodeBytes( + Buffer.from( + body + .replace(/=\r?\n/g, "") + .replace(/=([0-9A-F]{2})/gi, (_, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)), + ), + "latin1", + ), + charset, + ); return body; } /** Minimal MIME walk: the first text/plain part, else text/html with tags stripped. */ @@ -199,7 +226,11 @@ export function extractEmailText(raw: string, depth = 0): string { plain ?? texts.find((part) => /^text\/html/i.test(part.type)); return chosen ? extractEmailText(chosen.part, depth + 1) : ""; } - const decoded = decodeBody(body, headers.get("content-transfer-encoding")); + const decoded = decodeBody( + body, + headers.get("content-transfer-encoding"), + /charset="?([^";\s]+)"?/i.exec(type)?.[1], + ); return /^text\/html/i.test(type) ? decoded .replace(/|/gi, "") diff --git a/server/tests/responsibility-triggers.test.ts b/server/tests/responsibility-triggers.test.ts index 12a6f491c..e275389c6 100644 --- a/server/tests/responsibility-triggers.test.ts +++ b/server/tests/responsibility-triggers.test.ts @@ -696,6 +696,29 @@ describe("inbound email via SES -> SNS", () => { test("MIME text extraction prefers text/plain and decodes quoted-printable", () => { expect(extractEmailText(mime)).toBe("The disk on db-1 is at 99%."); }); + test("MIME text extraction decodes in the charset the part names", () => { + const latin1 = [ + "Content-Type: text/plain; charset=iso-8859-1", + "Content-Transfer-Encoding: quoted-printable", + "", + "Caf=E9 ferm=E9 =E0 minuit.", + ].join("\r\n"); + expect(extractEmailText(latin1)).toBe("Café fermé à minuit."); + const base64 = [ + 'Content-Type: text/plain; charset="windows-1252"', + "Content-Transfer-Encoding: base64", + "", + Buffer.from("Na\xefve r\xe9sum\xe9", "latin1").toString("base64"), + ].join("\r\n"); + expect(extractEmailText(base64)).toBe("Naïve résumé"); + const utf8 = [ + "Content-Type: text/plain; charset=utf-8", + "Content-Transfer-Encoding: quoted-printable", + "", + "Caf=C3=A9", + ].join("\r\n"); + expect(extractEmailText(utf8)).toBe("Café"); + }); test("signing certificate URLs must be HTTPS on an sns..amazonaws.com host", () => { expect(isSnsUrl(CERT_URL, true)).toBe(true); expect(