From a5996e1f6842fcef9f1aa59b63d27e4403dcd4ec Mon Sep 17 00:00:00 2001 From: Matt Stein Date: Mon, 13 Jul 2026 15:22:10 -0700 Subject: [PATCH 1/2] fix: forward List-Unsubscribe headers through SMTP proxy --- apps/smtp-server/src/server.ts | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/smtp-server/src/server.ts b/apps/smtp-server/src/server.ts index 9cb6a9fd..b34e3871 100644 --- a/apps/smtp-server/src/server.ts +++ b/apps/smtp-server/src/server.ts @@ -16,6 +16,39 @@ const SSL_KEY_PATH = const SSL_CERT_PATH = process.env.USESEND_API_CERT_PATH ?? process.env.UNSEND_API_CERT_PATH; +// Forwarded headers that mailparser's normalized Map can't be trusted for. +// Read from headerLines (raw) instead. +const FORWARDED_HEADERS = ["list-unsubscribe", "list-unsubscribe-post"]; + +function canonicalHeaderName(name: string): string { + return name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join("-"); +} + +function extractForwardedHeaders( + headerLines: readonly { key: string; line: string }[] | undefined, +): Record | undefined { + const result: Record = {}; + + for (const { key, line } of headerLines || []) { + if (!FORWARDED_HEADERS.includes(key)) { + continue; + } + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) { + continue; + } + const value = line.slice(colonIndex + 1).trim(); + if (value.length > 0) { + result[canonicalHeaderName(key)] = value; + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + async function sendEmailToUseSend(emailData: any, apiKey: string) { try { const apiEndpoint = "/api/v1/emails"; @@ -88,6 +121,8 @@ const serverOptions: SMTPServerOptions = { return callback(new Error("No API key found in session")); } + const forwardedHeaders = extractForwardedHeaders(parsed.headerLines); + const emailObject = { to: Array.isArray(parsed.to) ? parsed.to.map((addr) => addr.text).join(", ") @@ -99,6 +134,7 @@ const serverOptions: SMTPServerOptions = { text: parsed.text, html: parsed.html, replyTo: parsed.replyTo?.text, + headers: forwardedHeaders, attachments: parsed.attachments.length > 0 ? parsed.attachments.map((attachment, index) => ({ From fc1784c362a571ae33f1f64d05a5bf321cac850e Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Tue, 21 Jul 2026 07:21:26 +1000 Subject: [PATCH 2/2] fix: preserve safe SMTP headers --- apps/smtp-server/package.json | 5 +- apps/smtp-server/src/email-headers.ts | 104 ++++++++++++++++++ .../src/email-headers.unit.test.ts | 83 ++++++++++++++ apps/smtp-server/src/server.ts | 40 ++----- apps/web/src/server/utils/email-headers.ts | 61 ++++++++-- .../server/utils/email-headers.unit.test.ts | 4 + pnpm-lock.yaml | 3 + 7 files changed, 256 insertions(+), 44 deletions(-) create mode 100644 apps/smtp-server/src/email-headers.ts create mode 100644 apps/smtp-server/src/email-headers.unit.test.ts diff --git a/apps/smtp-server/package.json b/apps/smtp-server/package.json index 746c6859..4998435a 100644 --- a/apps/smtp-server/package.json +++ b/apps/smtp-server/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest run", "build": "tsup", "start": "node dist/server.js" }, @@ -23,6 +23,7 @@ "@types/node": "^22.15.2", "@types/nodemailer": "^8.0.0", "tsup": "^8.4.0", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^3.2.4" } } diff --git a/apps/smtp-server/src/email-headers.ts b/apps/smtp-server/src/email-headers.ts new file mode 100644 index 00000000..4e934d2c --- /dev/null +++ b/apps/smtp-server/src/email-headers.ts @@ -0,0 +1,104 @@ +import type { HeaderLines } from "mailparser"; + +// These headers are represented by first-class API fields, rebuilt when +// Nodemailer creates the outbound MIME message, or added by the receiving MTA. +const NON_FORWARDABLE_HEADERS = new Set([ + "authentication-results", + "bcc", + "cc", + "content-disposition", + "content-id", + "content-length", + "content-md5", + "content-transfer-encoding", + "content-type", + "date", + "delivered-to", + "dkim-signature", + "domainkey-signature", + "envelope-to", + "errors-to", + "from", + "message-id", + "mime-version", + "received", + "received-spf", + "reply-to", + "return-path", + "sender", + "subject", + "to", + "x-envelope-to", + "x-google-dkim-signature", + "x-original-to", + "x-received", +]); + +const NON_FORWARDABLE_PREFIXES = [ + "arc-", + "resent-", + "x-ses-", + "x-unsend-", + "x-usesend-", +]; + +const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +function shouldForwardHeader(name: string): boolean { + return ( + !NON_FORWARDABLE_HEADERS.has(name) && + !NON_FORWARDABLE_PREFIXES.some((prefix) => name.startsWith(prefix)) + ); +} + +/** + * Extracts end-to-end headers that remain meaningful after useSend rebuilds + * the MIME message. Repeated headers use the last value because the public API + * currently accepts a string record rather than an ordered header list. + */ +export function extractForwardedHeaders( + headerLines: HeaderLines | undefined, +): Record | undefined { + const headers = new Map(); + + for (const { key, line } of headerLines ?? []) { + const normalizedName = key.toLowerCase(); + if (!shouldForwardHeader(normalizedName)) { + continue; + } + + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) { + continue; + } + + const name = line.slice(0, colonIndex).trim(); + if ( + !HEADER_NAME_PATTERN.test(name) || + name.toLowerCase() !== normalizedName + ) { + continue; + } + + // headerLines contains the original folded representation. Unfold valid + // continuation lines before passing values through the JSON API. + const value = line + .slice(colonIndex + 1) + .replace(/\r?\n[ \t]+/g, " ") + .trim(); + + if (!value || /[\r\n]/.test(value)) { + continue; + } + + headers.set(normalizedName, { name, value }); + } + + if (headers.size === 0) { + return undefined; + } + + return Object.fromEntries( + [...headers.values()].map(({ name, value }) => [name, value]), + ); +} diff --git a/apps/smtp-server/src/email-headers.unit.test.ts b/apps/smtp-server/src/email-headers.unit.test.ts new file mode 100644 index 00000000..0fb5defc --- /dev/null +++ b/apps/smtp-server/src/email-headers.unit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { simpleParser } from "mailparser"; +import { extractForwardedHeaders } from "./email-headers"; + +describe("extractForwardedHeaders", () => { + it("forwards end-to-end and custom headers", async () => { + const parsed = await simpleParser( + [ + "From: sender@example.com", + "To: recipient@example.com", + "Subject: Header forwarding", + "List-Unsubscribe: ,", + " ", + "List-Unsubscribe-Post: List-Unsubscribe=One-Click", + "List-Help: ", + "In-Reply-To: ", + "References: ", + "Precedence: bulk", + "Auto-Submitted: auto-generated", + "Feedback-ID: campaign:customer:usesend", + "X-Custom-Trace: trace-123", + "", + "Hello", + ].join("\r\n"), + ); + + expect(extractForwardedHeaders(parsed.headerLines)).toEqual({ + "List-Unsubscribe": + ", ", + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Help": "", + "In-Reply-To": "", + References: " ", + Precedence: "bulk", + "Auto-Submitted": "auto-generated", + "Feedback-ID": "campaign:customer:usesend", + "X-Custom-Trace": "trace-123", + }); + }); + + it("does not forward headers that are rebuilt or transport-controlled", async () => { + const parsed = await simpleParser( + [ + "Return-Path: ", + "Received: from untrusted.example.com", + "Authentication-Results: mx.example.com; dkim=pass", + "ARC-Seal: i=1; a=rsa-sha256; d=example.com; b=stale", + "DKIM-Signature: v=1; d=example.com; b=stale", + "X-SES-CONFIGURATION-SET: untrusted", + "X-Usesend-Email-ID: spoofed", + "From: sender@example.com", + "To: recipient@example.com", + "Cc: copy@example.com", + "Bcc: hidden@example.com", + "Subject: Header forwarding", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "X-Safe: forwarded", + "", + "Hello", + ].join("\r\n"), + ); + + expect(extractForwardedHeaders(parsed.headerLines)).toEqual({ + "X-Safe": "forwarded", + }); + }); + + it("returns undefined when there is nothing safe to forward", async () => { + const parsed = await simpleParser( + [ + "From: sender@example.com", + "To: recipient@example.com", + "Subject: Header forwarding", + "", + "Hello", + ].join("\r\n"), + ); + + expect(extractForwardedHeaders(parsed.headerLines)).toBeUndefined(); + }); +}); diff --git a/apps/smtp-server/src/server.ts b/apps/smtp-server/src/server.ts index b34e3871..82b0ea7e 100644 --- a/apps/smtp-server/src/server.ts +++ b/apps/smtp-server/src/server.ts @@ -3,6 +3,7 @@ import { Readable } from "stream"; import dotenv from "dotenv"; import { simpleParser } from "mailparser"; import { readFileSync, watch, FSWatcher } from "fs"; +import { extractForwardedHeaders } from "./email-headers"; dotenv.config(); @@ -16,39 +17,6 @@ const SSL_KEY_PATH = const SSL_CERT_PATH = process.env.USESEND_API_CERT_PATH ?? process.env.UNSEND_API_CERT_PATH; -// Forwarded headers that mailparser's normalized Map can't be trusted for. -// Read from headerLines (raw) instead. -const FORWARDED_HEADERS = ["list-unsubscribe", "list-unsubscribe-post"]; - -function canonicalHeaderName(name: string): string { - return name - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join("-"); -} - -function extractForwardedHeaders( - headerLines: readonly { key: string; line: string }[] | undefined, -): Record | undefined { - const result: Record = {}; - - for (const { key, line } of headerLines || []) { - if (!FORWARDED_HEADERS.includes(key)) { - continue; - } - const colonIndex = line.indexOf(":"); - if (colonIndex === -1) { - continue; - } - const value = line.slice(colonIndex + 1).trim(); - if (value.length > 0) { - result[canonicalHeaderName(key)] = value; - } - } - - return Object.keys(result).length > 0 ? result : undefined; -} - async function sendEmailToUseSend(emailData: any, apiKey: string) { try { const apiEndpoint = "/api/v1/emails"; @@ -134,6 +102,12 @@ const serverOptions: SMTPServerOptions = { text: parsed.text, html: parsed.html, replyTo: parsed.replyTo?.text, + cc: Array.isArray(parsed.cc) + ? parsed.cc.map((addr) => addr.text).join(", ") + : parsed.cc?.text, + bcc: Array.isArray(parsed.bcc) + ? parsed.bcc.map((addr) => addr.text).join(", ") + : parsed.bcc?.text, headers: forwardedHeaders, attachments: parsed.attachments.length > 0 diff --git a/apps/web/src/server/utils/email-headers.ts b/apps/web/src/server/utils/email-headers.ts index 0fb01094..233fec70 100644 --- a/apps/web/src/server/utils/email-headers.ts +++ b/apps/web/src/server/utils/email-headers.ts @@ -1,8 +1,44 @@ import { nanoid } from "../nanoid"; -const RESERVED_EMAIL_HEADERS = new Set( - ["x-usesend-email-id"].map((header) => header.toLowerCase()) -); +const RESERVED_EMAIL_HEADERS = new Set([ + "authentication-results", + "bcc", + "cc", + "content-disposition", + "content-id", + "content-length", + "content-md5", + "content-transfer-encoding", + "content-type", + "date", + "delivered-to", + "dkim-signature", + "domainkey-signature", + "envelope-to", + "errors-to", + "from", + "message-id", + "mime-version", + "received", + "received-spf", + "reply-to", + "return-path", + "sender", + "subject", + "to", + "x-envelope-to", + "x-google-dkim-signature", + "x-original-to", + "x-received", +]); + +const RESERVED_EMAIL_HEADER_PREFIXES = [ + "arc-", + "resent-", + "x-ses-", + "x-unsend-", + "x-usesend-", +]; const HEADER_INJECTION_PATTERN = /[\r\n]/; @@ -13,14 +49,21 @@ const HEADER_INJECTION_PATTERN = /[\r\n]/; */ export function sanitizeHeader( rawName: unknown, - rawValue: unknown + rawValue: unknown, ): { name: string; value: string } | undefined { if (typeof rawName !== "string" || typeof rawValue !== "string") { return undefined; } const name = rawName.trim(); - if (!name || RESERVED_EMAIL_HEADERS.has(name.toLowerCase())) { + const normalizedName = name.toLowerCase(); + if ( + !name || + RESERVED_EMAIL_HEADERS.has(normalizedName) || + RESERVED_EMAIL_HEADER_PREFIXES.some((prefix) => + normalizedName.startsWith(prefix), + ) + ) { return undefined; } @@ -35,7 +78,7 @@ export function sanitizeHeader( } export function sanitizeCustomHeaders( - headers?: Record + headers?: Record, ): Record | undefined { if (!headers) { return undefined; @@ -44,7 +87,7 @@ export function sanitizeCustomHeaders( const sanitizedEntries = Object.entries(headers) .map(([name, value]) => sanitizeHeader(name, value)) .filter((entry): entry is { name: string; value: string } => - Boolean(entry) + Boolean(entry), ); if (sanitizedEntries.length === 0) { @@ -56,7 +99,7 @@ export function sanitizeCustomHeaders( acc[name] = value; return acc; }, - {} as Record + {} as Record, ); } @@ -75,7 +118,7 @@ export function buildHeaders({ }) { const sanitizedHeaders = sanitizeCustomHeaders(headers); const sanitizedHeaderNames = new Set( - Object.keys(sanitizedHeaders ?? {}).map((name) => name.toLowerCase()) + Object.keys(sanitizedHeaders ?? {}).map((name) => name.toLowerCase()), ); const defaultHeaders: Record = {}; diff --git a/apps/web/src/server/utils/email-headers.unit.test.ts b/apps/web/src/server/utils/email-headers.unit.test.ts index 5e801e70..75f055c6 100644 --- a/apps/web/src/server/utils/email-headers.unit.test.ts +++ b/apps/web/src/server/utils/email-headers.unit.test.ts @@ -8,6 +8,10 @@ import { describe("email header sanitization", () => { it("removes reserved and invalid headers", () => { expect(sanitizeHeader("x-usesend-email-id", "123")).toBeUndefined(); + expect(sanitizeHeader("Content-Type", "text/html")).toBeUndefined(); + expect(sanitizeHeader("DKIM-Signature", "v=1; stale")).toBeUndefined(); + expect(sanitizeHeader("ARC-Seal", "i=1; stale")).toBeUndefined(); + expect(sanitizeHeader("X-SES-CONFIGURATION-SET", "other")).toBeUndefined(); expect(sanitizeHeader("X-Test", "ok\r\nInjected: true")).toBeUndefined(); expect(sanitizeHeader(123, "ok")).toBeUndefined(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cce1a040..2f380c4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: typescript: specifier: ^5.8.3 version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(yaml@2.9.0) apps/web: dependencies: