From 8ab4d6a3e124e8e9919992f8aa9b6fe8bcb62d9c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:54:44 +0000 Subject: [PATCH 1/2] fix(deps): update dependency postal-mime to v3 --- packages/email-ingestion-gateway/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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/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 From b4d4201ef66bd6d73b300ec68f9abe9603ff3ffb Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 13 Aug 2026 14:28:31 +0300 Subject: [PATCH 2/2] Upgrade postal-mime to v3 and normalize header folding (#4201) * fix(deps): update dependency postal-mime to v3 * fix(email-ingestion-gateway): adapt to postal-mime v3 header unfolding postal-mime v3 unfolds RFC 5322 header folding by dropping the CRLF only, keeping the fold's space/tab inside `headers[].value` and inside display names parsed out of it; v2 collapsed every whitespace run itself. That leaked literal tabs into `originalFrom`/`fromDisplayName`, which breaks the server's `' via '` display-name split and its comparison against the tenant's own business names. Collapse whitespace runs in the extractor (header values and parsed display names) so the evidence keeps its v2 shape, and cover the v3 behavior changes we rely on with tests: folding, first-wins duplicate headers, and `replyTo` now being in document order (so [0] is the primary address). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EG3jNVhstn5fAac6GwBvvA --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Claude --- .../src/__tests__/mime-extractor.test.ts | 75 +++++++++++++++++++ .../src/mime-extractor.ts | 30 ++++++-- 2 files changed, 100 insertions(+), 5 deletions(-) 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; } } }