diff --git a/packages/integrations/src/google.ts b/packages/integrations/src/google.ts index c94e84256..b656f6570 100644 --- a/packages/integrations/src/google.ts +++ b/packages/integrations/src/google.ts @@ -297,6 +297,10 @@ const wellFormed = (value: string): string => "\uFFFD", ); +function isAttachedPart(part: GmailPart): boolean { + return Boolean(part.filename) || part.mimeType === "message/rfc822"; +} + function mapMessage(message: z.infer): Mail { const metadata = headers(message.payload); const plain: string[] = []; @@ -307,10 +311,10 @@ function mapMessage(message: z.infer): Mail { // Attachments carry their own content (or an attached message); never merge // their parts into the parent text. A remote body has no filename: it is // hydrated above and still counts as message text. - const attached = Boolean(part.filename) || part.mimeType === "message/rfc822"; - if (part.filename && part.body?.attachmentId) + const attached = isAttachedPart(part); + if (attached && part.body?.attachmentId) attachments.push( - `${message.id}:${part.body.attachmentId}:${encodeURIComponent(wellFormed(part.filename))}`, + `${message.id}:${part.body.attachmentId}:${encodeURIComponent(wellFormed(part.filename || "forwarded-message.eml"))}`, ); if ( !attached && @@ -470,8 +474,10 @@ export class GoogleClient { private async mapMessage(message: z.infer): Promise { const hydrate = async (part: GmailPart, depth: number): Promise => { if (depth > 30) throw new Error("Gmail message MIME nesting exceeds the limit"); + // Attached MIME trees are not part of the parent body. Leave their content + // lazy, matching the module-level mapMessage visible-text extractor. + if (isAttachedPart(part)) return; if ( - !part.filename && (part.mimeType === "text/plain" || part.mimeType === "text/html") && part.body?.data === undefined && part.body?.attachmentId diff --git a/tests/google.test.ts b/tests/google.test.ts index c5e607b23..05fd6f4c8 100644 --- a/tests/google.test.ts +++ b/tests/google.test.ts @@ -1018,3 +1018,47 @@ test("an outgoing attachment name with an unpaired surrogate still sends", async { name: "form\uD800.pdf", mimeType: "application/pdf", bytes: Buffer.from([1]) }, ]); }); + +test("attached MIME trees are not fetched while loading the parent message", async () => { + for (const attached of [ + { mimeType: "message/rfc822", filename: "forwarded.eml" }, + { mimeType: "message/rfc822" }, + { mimeType: "multipart/mixed", filename: "attached.mime" }, + ]) { + const reads: string[] = []; + const message = { + id: "outer", + threadId: "thread1", + payload: { + mimeType: "multipart/mixed", + parts: [ + { mimeType: "text/plain", body: { data: base64url("Outer body.") } }, + { + ...attached, + body: { attachmentId: "attached", size: 42 }, + parts: [{ mimeType: "text/plain", body: { attachmentId: "inner", size: 20 } }], + }, + ], + }, + }; + const client = clientWith((request) => { + const path = new URL(request.url).pathname; + reads.push(path); + if (path.includes("/attachments/")) + return json({ error: { message: "Unavailable attachment" } }, 404); + if (path.endsWith("/messages")) return json({ messages: [{ id: "outer" }] }); + if (path.includes("/threads/")) return json({ id: "thread1", messages: [message] }); + return json(message); + }); + const [mail] = await client.listMail(); + assert.equal(mail.body, "Outer body."); + assert.equal(reads.length, 2); + assert.deepEqual(mail.attachments, [ + `outer:attached:${attached.filename || "forwarded-message.eml"}`, + ]); + const [threadMail] = await client.getThread("thread1"); + assert.equal(threadMail.body, "Outer body."); + assert.deepEqual(threadMail.attachments, mail.attachments); + assert.equal(reads.length, 3); + } +});