Skip to content
Merged
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
23 changes: 17 additions & 6 deletions packages/integrations/src/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ function decodeHeader(value: string): string {
),
"latin1",
);
return new TextDecoder(charset).decode(bytes);
return new TextDecoder(charset.split("*")[0]).decode(bytes);
} catch {
return original;
}
Expand Down Expand Up @@ -191,7 +191,13 @@ function decodeSnippet(value: string): string {
);
}
function addresses(value: string): string[] {
return value.match(/[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+/g) ?? [];
// A quoted display name may itself contain an e-mail-looking string; only
// addresses outside display names count.
return (
value
.replace(/"(?:[^"\\]|\\.)*"\s*(?=<)/g, " ")
.match(/[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+/g) ?? []
);
}
/** Extract text from a parsed HTML tree. Nothing is rendered or fetched. */
function htmlToPlainText(html: string): string {
Expand Down Expand Up @@ -296,12 +302,16 @@ function mapMessage(message: z.infer<typeof messageSchema>): Mail {
const attachments: string[] = [];
const visit = (part: GmailPart, depth: number) => {
if (depth > 30) throw new Error("Gmail message MIME nesting exceeds the limit");
// 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)
attachments.push(
`${message.id}:${part.body.attachmentId}:${encodeURIComponent(wellFormed(part.filename))}`,
);
if (
!part.filename &&
!attached &&
(part.mimeType === "text/plain" || part.mimeType === "text/html") &&
part.body?.data
) {
Expand All @@ -313,14 +323,15 @@ function mapMessage(message: z.infer<typeof messageSchema>): Mail {
if (part.mimeType === "text/plain") plain.push(text);
else html.push(htmlToPlainText(text));
}
for (const child of part.parts ?? []) visit(child, depth + 1);
if (!attached) for (const child of part.parts ?? []) visit(child, depth + 1);
};
if (message.payload) visit(message.payload, 0);
const from = decodeHeader(metadata.get("from") ?? "");
const address = addresses(from)[0] ?? from;
const sender = from.includes("<")
const displayName = from.includes("<")
? from.slice(0, from.indexOf("<")).trim().replace(/^"|"$/g, "")
: address;
: "";
const sender = displayName || address;
const time = message.internalDate
? Number(message.internalDate)
: Date.parse(metadata.get("date") ?? "");
Expand Down
58 changes: 58 additions & 0 deletions tests/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,64 @@ test("single-event validation is repeated at execution and read failures never d
assert.equal(writes, 0);
});

test("mail parsing tolerates unknown charsets, RFC 2231 words, and display names with addresses", async () => {
const client = clientWith((request) =>
new URL(request.url).pathname.endsWith("/messages")
? json({ messages: [{ id: "msg-charset" }] })
: json({
id: "msg-charset",
threadId: "thread-charset",
payload: {
mimeType: "text/plain",
headers: [
{ name: "From", value: "<bare@example.com>" },
{ name: "To", value: '"billing@other.example" <real@example.com>' },
{ name: "Subject", value: "=?UTF-8*en?B?SGVsbG8=?=" },
{ name: "Content-Type", value: "text/plain; charset=unknown-8bit" },
],
body: { data: base64url("Body with unknown charset ✓") },
},
}),
);
const [mail] = await client.listMail();
assert.equal(mail.body, "Body with unknown charset ✓");
assert.equal(mail.subject, "Hello");
assert.equal(mail.from, "bare@example.com");
assert.equal(mail.sender, "bare@example.com");
assert.deepEqual(mail.to, ["real@example.com"]);
});

test("an attached message is not merged into the parent body", async () => {
const client = clientWith((request) =>
new URL(request.url).pathname.endsWith("/messages")
? json({ messages: [{ id: "msg-forward" }] })
: json({
id: "msg-forward",
threadId: "thread-forward",
payload: {
mimeType: "multipart/mixed",
parts: [
{ mimeType: "text/plain", body: { data: base64url("Outer body text.") } },
{
mimeType: "message/rfc822",
filename: "forwarded.eml",
body: { attachmentId: "attach2", size: 42 },
parts: [
{
mimeType: "text/plain",
body: { data: base64url("INNER ATTACHED MESSAGE BODY") },
},
],
},
],
},
}),
);
const [mail] = await client.listMail();
assert.equal(mail.body, "Outer body text.");
assert.deepEqual(mail.attachments, ["msg-forward:attach2:forwarded.eml"]);
});

test("a sender's unpaired-surrogate attachment filename does not break the inbox", async () => {
const client = clientWith((request) => {
const url = new URL(request.url);
Expand Down
Loading