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
14 changes: 10 additions & 4 deletions packages/integrations/src/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof messageSchema>): Mail {
const metadata = headers(message.payload);
const plain: string[] = [];
Expand All @@ -307,10 +311,10 @@ function mapMessage(message: z.infer<typeof messageSchema>): 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 &&
Expand Down Expand Up @@ -470,8 +474,10 @@ export class GoogleClient {
private async mapMessage(message: z.infer<typeof messageSchema>): Promise<Mail> {
const hydrate = async (part: GmailPart, depth: number): Promise<void> => {
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
Expand Down
44 changes: 44 additions & 0 deletions tests/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Loading