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 9cb6a9fd..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(); @@ -88,6 +89,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 +102,13 @@ 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 ? parsed.attachments.map((attachment, index) => ({ 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: