diff --git a/packages/email-ingestion-gateway/package.json b/packages/email-ingestion-gateway/package.json index 66b71b67b..26c23e5ee 100644 --- a/packages/email-ingestion-gateway/package.json +++ b/packages/email-ingestion-gateway/package.json @@ -54,7 +54,7 @@ "graphql-request": "7.4.0", "inline-css": "4.0.3", "playwright": "1.62.1", - "postal-mime": "2.7.6", + "postal-mime": "3.0.0", "zod": "4.4.3" }, "devDependencies": { diff --git a/packages/email-ingestion-gateway/src/__tests__/mime-extractor.test.ts b/packages/email-ingestion-gateway/src/__tests__/mime-extractor.test.ts index ab4744942..3bc38b509 100644 --- a/packages/email-ingestion-gateway/src/__tests__/mime-extractor.test.ts +++ b/packages/email-ingestion-gateway/src/__tests__/mime-extractor.test.ts @@ -444,6 +444,81 @@ describe('extractFromMime — senderEvidence', () => { expect(result.senderEvidence.originalSender).toBe('orig-sender@vendor.com'); }); + // postal-mime v3 unfolds long headers by dropping the CRLF only, leaving the fold's + // space/tab inside the value (v2 collapsed every whitespace run itself). Everything + // downstream — the server's `' via '` split, its display-name comparison against the + // tenant's own business names — assumes single spaces, so the extractor normalizes. + it('collapses the whitespace RFC 5322 folding leaves in header values', async () => { + const mime = Buffer.from( + [ + 'From: sender@example.com', + 'To: invoices@example.com', + 'Subject: Test', + 'X-Original-From: "Vendor Ltd" via', + '\tAccount Payables ', + 'Content-Type: text/plain', + '', + 'body', + ].join('\r\n'), + 'utf8', + ); + const result = (await extractFromMime(mime)) as { success: true; senderEvidence: SenderEvidence }; + expect(result.senderEvidence.originalFrom).toBe( + '"Vendor Ltd" via Account Payables ', + ); + }); + + it('collapses folding whitespace inside the From display name', async () => { + const mime = Buffer.from( + [ + 'From: "Vendor Ltd" via', + '\tAccount Payables ', + 'To: invoices@example.com', + 'Subject: Test', + 'Content-Type: text/plain', + '', + 'body', + ].join('\r\n'), + 'utf8', + ); + const result = (await extractFromMime(mime)) as { success: true; senderEvidence: SenderEvidence }; + expect(result.senderEvidence.fromDisplayName).toBe('Vendor Ltd via Account Payables'); + expect(result.senderEvidence.from).toBe('Vendor Ltd via Account Payables '); + }); + + it('takes the first Reply-To address as the primary one', async () => { + const mime = makeMultipartMime({ + replyTo: 'primary@vendor.com, secondary@vendor.com', + attachments: [{ filename: 'a.pdf', mimeType: 'application/pdf', content: fakePdf() }], + }); + const result = (await extractFromMime(mime)) as { success: true; senderEvidence: SenderEvidence }; + expect(result.senderEvidence.replyTo).toBe('primary@vendor.com'); + }); + + it('resolves a duplicated single-value header to its first occurrence', async () => { + const mime = Buffer.from( + [ + 'From: sender@example.com', + 'To: invoices@example.com', + 'Subject: First', + 'Subject: Second', + 'X-Original-Sender: first@vendor.com', + 'X-Original-Sender: second@vendor.com', + 'Content-Type: text/plain', + '', + 'body', + ].join('\r\n'), + 'utf8', + ); + const result = (await extractFromMime(mime)) as { + success: true; + subject: string; + senderEvidence: SenderEvidence; + }; + expect(result.subject).toBe('First'); + expect(result.senderEvidence.originalSender).toBe('first@vendor.com'); + }); + it('returns undefined for absent optional headers', async () => { const mime = makeMultipartMime({ attachments: [{ filename: 'a.pdf', mimeType: 'application/pdf', content: fakePdf() }], diff --git a/packages/email-ingestion-gateway/src/mime-extractor.ts b/packages/email-ingestion-gateway/src/mime-extractor.ts index ca871fac8..8f0af261c 100644 --- a/packages/email-ingestion-gateway/src/mime-extractor.ts +++ b/packages/email-ingestion-gateway/src/mime-extractor.ts @@ -131,7 +131,9 @@ export async function extractFromMime(rawMime: Buffer): Promise` (or just the address / display name). */ function formatAddress(addr: Address | undefined): string | undefined { if (!addr) return undefined; - const name = addr.name?.trim(); + const name = unfold(addr.name); const address = addr.address?.trim(); if (name && address) return `${name} <${address}>`; return address || name || undefined; @@ -213,12 +230,15 @@ function headerValue( ): string | undefined { for (const name of names) { const lower = name.toLowerCase(); + // First occurrence wins, matching postal-mime v3's own duplicate-header resolution + // for single-value headers such as `subject` / `from`. const found = headers.find(header => header.key.toLowerCase() === lower); - if (found?.value) { + const value = unfold(found?.value); + if (value) { try { - return decodeWords(found.value); + return decodeWords(value); } catch { - return found.value; + return value; } } } diff --git a/yarn.lock b/yarn.lock index 8af2aa8a7..d79a1e966 100644 --- a/yarn.lock +++ b/yarn.lock @@ -149,7 +149,7 @@ __metadata: graphql-request: "npm:7.4.0" inline-css: "npm:4.0.3" playwright: "npm:1.62.1" - postal-mime: "npm:2.7.6" + postal-mime: "npm:3.0.0" tsx: "npm:4.23.12" typescript: "npm:6.0.3" wrangler: "npm:4.122.0" @@ -23248,10 +23248,10 @@ __metadata: languageName: node linkType: hard -"postal-mime@npm:2.7.6": - version: 2.7.6 - resolution: "postal-mime@npm:2.7.6" - checksum: 10c0/7932b9b5c82e9d3f25607d9f07eba92414b42a304592f8cd03f9df058311af8d5df091ea8ef05ce5f20f052cbd858bdda8df0903d547a54953814ca57a68df03 +"postal-mime@npm:3.0.0": + version: 3.0.0 + resolution: "postal-mime@npm:3.0.0" + checksum: 10c0/e7385b3ceccba9e14a0c81d46961ab5198d8bf12ce7a43925349deb7d38d647d319555379c70d1bfc437bb0e765cae062cac3105a7092d5b676223749d0d9010 languageName: node linkType: hard