diff --git a/packages/integrations/src/google.ts b/packages/integrations/src/google.ts index c94e84256..71bcf163e 100644 --- a/packages/integrations/src/google.ts +++ b/packages/integrations/src/google.ts @@ -188,7 +188,10 @@ function decodeSnippet(value: string): string { entity[1].toLowerCase() === "x" ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10); - return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : original; + // Surrogates are not scalar values: fromCodePoint throws on them, and one + // such reference in a snippet must not reject the whole message list. + const scalar = code >= 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff); + return scalar ? String.fromCodePoint(code) : original; }, ); } diff --git a/tests/google.test.ts b/tests/google.test.ts index c5e607b23..e9fedc53e 100644 --- a/tests/google.test.ts +++ b/tests/google.test.ts @@ -139,6 +139,19 @@ test("header decoding preserves spaces encoded inside words and next to plain te assert.equal((await client.getThread("thread1"))[0].subject, "Re: Visit résumé notes"); }); +test("a numeric character reference to a surrogate stays literal instead of failing the list", async () => { + // String.fromCodePoint throws on surrogate code points; a sender can put the + // literal text � in a snippet, and one such message must not reject + // the whole list (Promise.all over per-message parses). + const client = clientWith((request) => + new URL(request.url).pathname.endsWith("/messages") + ? json({ messages: [{ id: "msg1" }] }) + : json({ id: "msg1", threadId: "thread1", snippet: "Entity notes: � and �" }), + ); + const [mail] = await client.listMail(); + assert.equal(mail.body, "Entity notes: � and �"); +}); + 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" };