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
33 changes: 26 additions & 7 deletions packages/integrations/src/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,21 @@ function decodeHeader(value: string): string {
),
"latin1",
);
return new TextDecoder(charset).decode(bytes);
return new TextDecoder(charset.split("*")[0]).decode(bytes);
} catch {
return original;
}
},
);
}
/** Decode a message-body part; unknown or malformed charset labels fall back to UTF-8. */
function decodeText(bytes: Buffer, charset: string): string {
try {
return new TextDecoder(charset).decode(bytes);
} catch {
return new TextDecoder("utf-8").decode(bytes);
}
}
function decodeSnippet(value: string): string {
const entities: Record<string, string> = {
amp: "&",
Expand All @@ -191,7 +199,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 @@ -277,31 +291,36 @@ 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(part.filename)}`,
);
if (
!part.filename &&
!attached &&
(part.mimeType === "text/plain" || part.mimeType === "text/html") &&
part.body?.data
) {
const charset =
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 = decodeText(decodeBase64url(part.body.data, 1024 * 1024), charset);
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 @@ -758,3 +758,61 @@ test("single-event validation is repeated at execution and read failures never d
await assert.rejects(failing.deleteEvent("primary", "event-1"), GoogleApiError);
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"]);
});