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
2 changes: 1 addition & 1 deletion packages/email-ingestion-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <billing@vendor.com>',
'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 <billing@vendor.com>',
);
});

it('collapses folding whitespace inside the From display name', async () => {
const mime = Buffer.from(
[
'From: "Vendor Ltd" via',
'\tAccount Payables <group@tenant.example>',
'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 <group@tenant.example>');
});

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() }],
Expand Down
30 changes: 25 additions & 5 deletions packages/email-ingestion-gateway/src/mime-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export async function extractFromMime(rawMime: Buffer): Promise<ExtractionResult
body,
senderEvidence: {
from: formatAddress(email.from),
fromDisplayName: email.from?.name?.trim() || undefined,
fromDisplayName: unfold(email.from?.name) || undefined,
// postal-mime v3 returns address lists in document order (v2 reversed them),
// so [0] is the header's first — i.e. primary — Reply-To address.
replyTo: formatAddress(email.replyTo?.[0]),
// Kept as separate fields: X-Original-From is the original *author* (often a
// display name only), X-Original-Sender the relaying platform. Reading them as
Expand Down Expand Up @@ -194,10 +196,25 @@ function toBuffer(content: ArrayBuffer | Uint8Array | string): Buffer {
return Buffer.from(new Uint8Array(content));
}

/**
* Collapse every whitespace run to a single space and trim.
*
* Header folding (RFC 5322) inserts `CRLF` + a space **or tab** at arbitrary points in
* a long header, and postal-mime v3 unfolds by dropping only the `CRLF` — so the fold's
* tab (or run of spaces) survives inside the value and inside display names parsed out
* of it. v2 collapsed those runs itself; everything reading this evidence assumes single
* spaces (`splitViaDisplayName` looks for a literal `' via '`, the server compares display
* names against the tenant's own business names), so normalize here rather than teaching
* every consumer about folding.
*/
function unfold(value: string | null | undefined): string {
return value ? value.replaceAll(/\s+/g, ' ').trim() : '';
}

/** Render a parsed address as `Name <addr>` (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;
Expand All @@ -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;
}
}
}
Expand Down
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
Loading