Skip to content
Merged
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
5 changes: 3 additions & 2 deletions apps/smtp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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"
}
}
104 changes: 104 additions & 0 deletions apps/smtp-server/src/email-headers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined {
const headers = new Map<string, { name: string; value: string }>();

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]),
);
}
83 changes: 83 additions & 0 deletions apps/smtp-server/src/email-headers.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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: <mailto:unsubscribe@example.com>,",
" <https://example.com/unsubscribe/recipient-token>",
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
"List-Help: <https://example.com/help>",
"In-Reply-To: <previous@example.com>",
"References: <first@example.com> <previous@example.com>",
"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":
"<mailto:unsubscribe@example.com>, <https://example.com/unsubscribe/recipient-token>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Help": "<https://example.com/help>",
"In-Reply-To": "<previous@example.com>",
References: "<first@example.com> <previous@example.com>",
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: <bounce@example.com>",
"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: <old@example.com>",
"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();
});
});
10 changes: 10 additions & 0 deletions apps/smtp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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(", ")
Expand All @@ -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) => ({
Expand Down
61 changes: 52 additions & 9 deletions apps/web/src/server/utils/email-headers.ts
Original file line number Diff line number Diff line change
@@ -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]/;

Expand All @@ -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;
}

Expand All @@ -35,7 +78,7 @@ export function sanitizeHeader(
}

export function sanitizeCustomHeaders(
headers?: Record<string, string | null | undefined>
headers?: Record<string, string | null | undefined>,
): Record<string, string> | undefined {
if (!headers) {
return undefined;
Expand All @@ -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) {
Expand All @@ -56,7 +99,7 @@ export function sanitizeCustomHeaders(
acc[name] = value;
return acc;
},
{} as Record<string, string>
{} as Record<string, string>,
);
}

Expand All @@ -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<string, string> = {};
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/server/utils/email-headers.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading