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;