From 9c346033765fc10e6e9afb815a66c793c2f53195 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:33:52 +0900 Subject: [PATCH] fix: decode mail bodies with an unknown charset as UTF-8 instead of failing the list --- packages/integrations/src/google.ts | 10 +++++++++- tests/google.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/integrations/src/google.ts b/packages/integrations/src/google.ts index cb21f2c54..3bacea7f7 100644 --- a/packages/integrations/src/google.ts +++ b/packages/integrations/src/google.ts @@ -270,6 +270,14 @@ function htmlToPlainText(html: string): string { .replace(/\n{3,}/g, "\n\n") .trim(); } +/** UTF-8 when the declared charset is not one TextDecoder knows (`unknown-8bit`, `utf-7`). */ +function decoderFor(charset: string): TextDecoder { + try { + return new TextDecoder(charset); + } catch { + return new TextDecoder(); + } +} function mapMessage(message: z.infer): Mail { const metadata = headers(message.payload); const plain: string[] = []; @@ -290,7 +298,7 @@ function mapMessage(message: z.infer): Mail { headers(part) .get("content-type") ?.match(/charset=["']?([^;"'\s]+)/i)?.[1] ?? "utf-8"; - const text = new TextDecoder(charset).decode(decodeBase64url(part.body.data, 1024 * 1024)); + const text = decoderFor(charset).decode(decodeBase64url(part.body.data, 1024 * 1024)); if (part.mimeType === "text/plain") plain.push(text); else html.push(htmlToPlainText(text)); } diff --git a/tests/google.test.ts b/tests/google.test.ts index 9b2e64569..233eb563f 100644 --- a/tests/google.test.ts +++ b/tests/google.test.ts @@ -88,6 +88,35 @@ test("mail reads nested plain text and attachment references over authenticated assert.deepEqual(mail.attachments, ["msg1:attach1:Permission%3A%20form.pdf"]); }); +test("a body charset TextDecoder does not know falls back to UTF-8 instead of failing the list", async () => { + const charsets: Record = { known: "utf-8", unknown: "unknown-8bit" }; + const bodies: Record = { known: "Opening hours", unknown: "Café tickets" }; + const client = clientWith((request) => { + const url = new URL(request.url); + if (url.pathname.endsWith("/messages")) + return json({ messages: [{ id: "known" }, { id: "unknown" }] }); + const id = url.pathname.split("/").at(-1) ?? ""; + return json({ + id, + threadId: `thread-${id}`, + labelIds: ["INBOX"], + payload: { + headers: [ + { name: "From", value: "Museum " }, + { name: "Content-Type", value: `text/plain; charset=${charsets[id]}` }, + ], + mimeType: "text/plain", + body: { data: base64url(bodies[id]) }, + }, + }); + }); + const mail = await client.listMail(); + assert.deepEqual( + mail.map((item) => item.body), + ["Opening hours", "Café tickets"], + ); +}); + test("HTML-only messages expose complete plain text while removing active and non-content elements", async () => { let reads = 0; const fullText = "Complete museum itinerary. ".repeat(4500);