From 890ad72057cf1cd61a021c87cd9900cf4f2c3a0d Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sun, 28 Sep 2025 21:33:45 +1000 Subject: [PATCH] feat: add custom email headers (#260) --- apps/docs/api-reference/openapi.json | 16 +++ apps/docs/get-started/nodejs.mdx | 5 + apps/docs/get-started/python.mdx | 3 + .../migration.sql | 2 + apps/web/prisma/schema.prisma | 1 + apps/web/src/server/aws/ses.ts | 29 ++--- .../server/public-api/schemas/email-schema.ts | 3 + .../src/server/service/email-queue-service.ts | 16 ++- apps/web/src/server/service/email-service.ts | 21 ++- apps/web/src/server/utils/email-headers.ts | 121 ++++++++++++++++++ apps/web/src/types/index.ts | 1 + packages/python-sdk/pyproject.toml | 2 +- packages/python-sdk/usesend/types.py | 2 + packages/sdk/package.json | 2 +- packages/sdk/types/schema.d.ts | 8 ++ 15 files changed, 202 insertions(+), 30 deletions(-) create mode 100644 apps/web/prisma/migrations/20250927193506_add_email_headers/migration.sql create mode 100644 apps/web/src/server/utils/email-headers.ts diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 2c3f131..521b885 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1140,6 +1140,14 @@ "nullable": true, "minLength": 1 }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Custom headers to included with the emails" + }, "attachments": { "type": "array", "items": { @@ -1288,6 +1296,14 @@ "nullable": true, "minLength": 1 }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "description": "Custom headers to included with the emails" + }, "attachments": { "type": "array", "items": { diff --git a/apps/docs/get-started/nodejs.mdx b/apps/docs/get-started/nodejs.mdx index 46cc076..afd8a2a 100644 --- a/apps/docs/get-started/nodejs.mdx +++ b/apps/docs/get-started/nodejs.mdx @@ -57,8 +57,13 @@ icon: node-js subject: "useSend email", html: "

useSend is the best open source product to send emails

", text: "useSend is the best open source product to send emails", + headers: { + "X-Campaign": "welcome", + }, }); ``` + + > Custom headers are forwarded as-is. useSend only manages the `X-Usesend-Email-ID` and `References` headers. diff --git a/apps/docs/get-started/python.mdx b/apps/docs/get-started/python.mdx index fa875f3..9417935 100644 --- a/apps/docs/get-started/python.mdx +++ b/apps/docs/get-started/python.mdx @@ -41,12 +41,15 @@ payload: types.EmailCreate = { "from": "no-reply@yourdomain.com", "subject": "Welcome", "html": "Hello!", + "headers": {"X-Campaign": "welcome"}, } data, err = client.emails.send(payload) print(data or err) ``` +useSend forwards your custom headers to SES. Only the `X-Usesend-Email-ID` and `References` headers are managed automatically. + Attachments and scheduling: ```python diff --git a/apps/web/prisma/migrations/20250927193506_add_email_headers/migration.sql b/apps/web/prisma/migrations/20250927193506_add_email_headers/migration.sql new file mode 100644 index 0000000..0a573da --- /dev/null +++ b/apps/web/prisma/migrations/20250927193506_add_email_headers/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Email" ADD COLUMN "headers" TEXT; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 8e9e546..3c898ce 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -259,6 +259,7 @@ model Email { campaignId String? contactId String? inReplyToId String? + headers String? team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) emailEvents EmailEvent[] diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index 0bb413a..1b3296b 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -17,8 +17,8 @@ import { generateKeyPairSync } from "crypto"; import nodemailer from "nodemailer"; import { env } from "~/env"; import { EmailContent } from "~/types"; -import { nanoid } from "../nanoid"; import { logger } from "../logger/log"; +import { buildHeaders } from "~/server/utils/email-headers"; let accountId: string | undefined = undefined; @@ -201,6 +201,7 @@ export async function sendRawEmail({ inReplyToMessageId, emailId, sesTenantId, + headers, }: Partial & { region: string; configurationSetName: string; @@ -232,25 +233,13 @@ export async function sendRawEmail({ replyTo, cc, bcc, - headers: { - "X-Entity-Ref-ID": nanoid(), - ...(emailId - ? { "X-Usesend-Email-ID": emailId, "X-Unsend-Email-ID": emailId } - : {}), - ...(unsubUrl - ? { - "List-Unsubscribe": `<${unsubUrl}>`, - "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", - } - : {}), - ...(isBulk ? { Precedence: "bulk" } : {}), - ...(inReplyToMessageId - ? { - "In-Reply-To": `<${inReplyToMessageId}@email.amazonses.com>`, - References: `<${inReplyToMessageId}@email.amazonses.com>`, - } - : {}), - }, + headers: buildHeaders({ + emailId, + headers, + unsubUrl, + isBulk, + inReplyToMessageId, + }), }); const chunks = []; diff --git a/apps/web/src/server/public-api/schemas/email-schema.ts b/apps/web/src/server/public-api/schemas/email-schema.ts index 9b23ac5..1a49a08 100644 --- a/apps/web/src/server/public-api/schemas/email-schema.ts +++ b/apps/web/src/server/public-api/schemas/email-schema.ts @@ -19,6 +19,9 @@ export const emailSchema = z bcc: z.string().or(z.array(z.string())).optional(), text: z.string().min(1).optional().nullable(), html: z.coerce.string().min(1).optional().nullable(), + headers: z.record(z.string().min(1)).optional().openapi({ + description: "Custom headers to included with the emails", + }), attachments: z .array( z.object({ diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index dba065f..730eedb 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -10,6 +10,7 @@ import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; import { logger } from "../logger/log"; import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; import { LimitService } from "./limit-service"; +import { sanitizeCustomHeaders } from "~/server/utils/email-headers"; // Notifications about limits are handled inside LimitService. type QueueEmailJob = TeamJob<{ @@ -127,7 +128,13 @@ export class EmailQueueService { } queue.add( emailId, - { emailId, timestamp: Date.now(), unsubUrl, isBulk, teamId }, + { + emailId, + timestamp: Date.now(), + unsubUrl, + isBulk, + teamId, + }, { jobId: emailId, delay, ...DEFAULT_QUEUE_OPTIONS } ); } @@ -390,6 +397,8 @@ async function executeEmail(job: QueueEmailJob) { return; } + const customHeaders = email.headers ? JSON.parse(email.headers) : undefined; + const messageId = await sendRawEmail({ to: email.to, from: email.from, @@ -407,6 +416,7 @@ async function executeEmail(job: QueueEmailJob) { inReplyToMessageId, emailId: email.id, sesTenantId: domain?.sesTenantId, + headers: customHeaders, }); logger.info( @@ -414,10 +424,10 @@ async function executeEmail(job: QueueEmailJob) { `[EmailQueueService]: Email sent` ); - // Delete attachments after sending the email + // Delete attachments and headers after sending the email await db.email.update({ where: { id: email.id }, - data: { sesEmailId: messageId, text, attachments: null }, + data: { sesEmailId: messageId, text, attachments: null, headers: null }, }); } catch (error: any) { await db.emailEvent.create({ diff --git a/apps/web/src/server/service/email-service.ts b/apps/web/src/server/service/email-service.ts index ddf9731..58a36e1 100644 --- a/apps/web/src/server/service/email-service.ts +++ b/apps/web/src/server/service/email-service.ts @@ -2,10 +2,15 @@ import { EmailContent } from "~/types"; import { db } from "../db"; import { UnsendApiError } from "~/server/public-api/api-error"; import { EmailQueueService } from "./email-queue-service"; -import { validateDomainFromEmail, validateApiKeyDomainAccess } from "./domain-service"; +import { + validateDomainFromEmail, + validateApiKeyDomainAccess, +} from "./domain-service"; import { EmailRenderer } from "@usesend/email-editor/src/renderer"; import { logger } from "../logger/log"; import { SuppressionService } from "./suppression-service"; +import { sanitizeCustomHeaders } from "~/server/utils/email-headers"; +import { Prisma } from "@prisma/client"; async function checkIfValidEmail(emailId: string) { const email = await db.email.findUnique({ @@ -66,26 +71,27 @@ export async function sendEmail( scheduledAt, apiKeyId, inReplyToId, + headers, } = emailContent; let subject = subjectFromApiCall; let html = htmlFromApiCall; let domain: Awaited>; - + // If this is an API call with an API key, validate domain access if (apiKeyId) { const apiKey = await db.apiKey.findUnique({ where: { id: apiKeyId }, include: { domain: true }, }); - + if (!apiKey) { throw new UnsendApiError({ code: "BAD_REQUEST", message: "Invalid API key", }); } - + domain = await validateApiKeyDomainAccess(from, teamId, apiKey); } else { // For non-API calls (dashboard, etc.), use regular domain validation @@ -261,6 +267,7 @@ export async function sendEmail( latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED", apiId: apiKeyId, inReplyToId, + headers: headers ? JSON.stringify(headers) : undefined, }, }); @@ -556,6 +563,9 @@ export async function sendBulkEmails( latestStatus: "SUPPRESSED", apiId: apiKeyId, inReplyToId, + headers: originalContent.headers + ? JSON.stringify(originalContent.headers) + : undefined, }, }); @@ -628,6 +638,7 @@ export async function sendBulkEmails( bcc, scheduledAt, apiKeyId, + headers, } = content; // Find the original index for this email @@ -691,7 +702,6 @@ export async function sendBulkEmails( : undefined; try { - // Create email record const email = await db.email.create({ data: { to: Array.isArray(to) ? to : [to], @@ -712,6 +722,7 @@ export async function sendBulkEmails( scheduledAt: scheduledAtDate, latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED", apiId: apiKeyId, + headers: headers ? JSON.stringify(headers) : undefined, }, }); diff --git a/apps/web/src/server/utils/email-headers.ts b/apps/web/src/server/utils/email-headers.ts new file mode 100644 index 0000000..0fb0109 --- /dev/null +++ b/apps/web/src/server/utils/email-headers.ts @@ -0,0 +1,121 @@ +import { nanoid } from "../nanoid"; + +const RESERVED_EMAIL_HEADERS = new Set( + ["x-usesend-email-id"].map((header) => header.toLowerCase()) +); + +const HEADER_INJECTION_PATTERN = /[\r\n]/; + +/** + * Removes reserved headers and values that could result in header injection. + * Returns `undefined` when the resulting object is empty so downstream callers + * can skip persisting redundant data. + */ +export function sanitizeHeader( + rawName: 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())) { + return undefined; + } + + if ( + HEADER_INJECTION_PATTERN.test(name) || + HEADER_INJECTION_PATTERN.test(rawValue) + ) { + return undefined; + } + + return { name, value: rawValue }; +} + +export function sanitizeCustomHeaders( + headers?: Record +): Record | undefined { + if (!headers) { + return undefined; + } + + const sanitizedEntries = Object.entries(headers) + .map(([name, value]) => sanitizeHeader(name, value)) + .filter((entry): entry is { name: string; value: string } => + Boolean(entry) + ); + + if (sanitizedEntries.length === 0) { + return undefined; + } + + return sanitizedEntries.reduce( + (acc, { name, value }) => { + acc[name] = value; + return acc; + }, + {} as Record + ); +} + +export function buildHeaders({ + emailId, + headers, + unsubUrl, + isBulk, + inReplyToMessageId, +}: { + emailId?: string | undefined; + headers?: Record | undefined; + unsubUrl?: string; + isBulk?: boolean; + inReplyToMessageId?: string | undefined; +}) { + const sanitizedHeaders = sanitizeCustomHeaders(headers); + const sanitizedHeaderNames = new Set( + Object.keys(sanitizedHeaders ?? {}).map((name) => name.toLowerCase()) + ); + + const defaultHeaders: Record = {}; + + if (!sanitizedHeaderNames.has("x-entity-ref-id")) { + defaultHeaders["X-Entity-Ref-ID"] = nanoid(); + } + + if (emailId) { + defaultHeaders["X-Usesend-Email-ID"] = emailId; + } + + if (unsubUrl) { + if (!sanitizedHeaderNames.has("list-unsubscribe")) { + defaultHeaders["List-Unsubscribe"] = `<${unsubUrl}>`; + } + + if (!sanitizedHeaderNames.has("list-unsubscribe-post")) { + defaultHeaders["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"; + } + } + + if (isBulk && !sanitizedHeaderNames.has("precedence")) { + defaultHeaders["Precedence"] = "bulk"; + } + + if (inReplyToMessageId) { + const formattedMessageId = `<${inReplyToMessageId}@email.amazonses.com>`; + + if (!sanitizedHeaderNames.has("in-reply-to")) { + defaultHeaders["In-Reply-To"] = formattedMessageId; + } + + if (!sanitizedHeaderNames.has("references")) { + defaultHeaders["References"] = formattedMessageId; + } + } + + return { + ...defaultHeaders, + ...(sanitizedHeaders ?? {}), + }; +} diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 09ea561..f71955c 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -10,6 +10,7 @@ export type EmailContent = { cc?: string | string[]; bcc?: string | string[]; attachments?: Array; + headers?: Record; unsubUrl?: string; scheduledAt?: string; inReplyToId?: string | null; diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index 51b390c..155d7fe 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "usesend" -version = "0.2.4" +version = "0.2.5" description = "Python SDK for the UseSend API" authors = ["UseSend"] license = "MIT" diff --git a/packages/python-sdk/usesend/types.py b/packages/python-sdk/usesend/types.py index c65ee96..0cbeae5 100644 --- a/packages/python-sdk/usesend/types.py +++ b/packages/python-sdk/usesend/types.py @@ -215,6 +215,7 @@ EmailCreate = TypedDict( 'attachments': NotRequired[List[Attachment]], 'scheduledAt': NotRequired[Union[datetime, str]], 'inReplyToId': NotRequired[str], + 'headers': NotRequired[Dict[str, str]], } ) @@ -239,6 +240,7 @@ EmailBatchItem = TypedDict( 'attachments': NotRequired[List[Attachment]], 'scheduledAt': NotRequired[Union[datetime, str]], 'inReplyToId': NotRequired[str], + 'headers': NotRequired[Dict[str, str]], } ) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 0354273..944f976 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "usesend-js", - "version": "1.5.3", + "version": "1.5.4", "description": "", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts index deb7100..96ec038 100644 --- a/packages/sdk/types/schema.d.ts +++ b/packages/sdk/types/schema.d.ts @@ -535,6 +535,10 @@ export interface paths { bcc?: string | string[]; text?: string | null; html?: string | null; + /** @description Custom headers to included with the emails */ + headers?: { + [key: string]: string; + }; attachments?: { filename: string; content: string; @@ -598,6 +602,10 @@ export interface paths { bcc?: string | string[]; text?: string | null; html?: string | null; + /** @description Custom headers to included with the emails */ + headers?: { + [key: string]: string; + }; attachments?: { filename: string; content: string;