feat: add custom email headers (#260)

This commit is contained in:
KM Koushik
2025-09-28 21:33:45 +10:00
committed by GitHub
parent 1a00999bf0
commit 890ad72057
15 changed files with 202 additions and 30 deletions
+16
View File
@@ -1140,6 +1140,14 @@
"nullable": true, "nullable": true,
"minLength": 1 "minLength": 1
}, },
"headers": {
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 1
},
"description": "Custom headers to included with the emails"
},
"attachments": { "attachments": {
"type": "array", "type": "array",
"items": { "items": {
@@ -1288,6 +1296,14 @@
"nullable": true, "nullable": true,
"minLength": 1 "minLength": 1
}, },
"headers": {
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 1
},
"description": "Custom headers to included with the emails"
},
"attachments": { "attachments": {
"type": "array", "type": "array",
"items": { "items": {
+5
View File
@@ -57,8 +57,13 @@ icon: node-js
subject: "useSend email", subject: "useSend email",
html: "<p>useSend is the best open source product to send emails</p>", html: "<p>useSend is the best open source product to send emails</p>",
text: "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.
</Step> </Step>
</Steps> </Steps>
+3
View File
@@ -41,12 +41,15 @@ payload: types.EmailCreate = {
"from": "no-reply@yourdomain.com", "from": "no-reply@yourdomain.com",
"subject": "Welcome", "subject": "Welcome",
"html": "<strong>Hello!</strong>", "html": "<strong>Hello!</strong>",
"headers": {"X-Campaign": "welcome"},
} }
data, err = client.emails.send(payload) data, err = client.emails.send(payload)
print(data or err) 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: Attachments and scheduling:
```python ```python
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Email" ADD COLUMN "headers" TEXT;
+1
View File
@@ -259,6 +259,7 @@ model Email {
campaignId String? campaignId String?
contactId String? contactId String?
inReplyToId String? inReplyToId String?
headers String?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
emailEvents EmailEvent[] emailEvents EmailEvent[]
+9 -20
View File
@@ -17,8 +17,8 @@ import { generateKeyPairSync } from "crypto";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { env } from "~/env"; import { env } from "~/env";
import { EmailContent } from "~/types"; import { EmailContent } from "~/types";
import { nanoid } from "../nanoid";
import { logger } from "../logger/log"; import { logger } from "../logger/log";
import { buildHeaders } from "~/server/utils/email-headers";
let accountId: string | undefined = undefined; let accountId: string | undefined = undefined;
@@ -201,6 +201,7 @@ export async function sendRawEmail({
inReplyToMessageId, inReplyToMessageId,
emailId, emailId,
sesTenantId, sesTenantId,
headers,
}: Partial<EmailContent> & { }: Partial<EmailContent> & {
region: string; region: string;
configurationSetName: string; configurationSetName: string;
@@ -232,25 +233,13 @@ export async function sendRawEmail({
replyTo, replyTo,
cc, cc,
bcc, bcc,
headers: { headers: buildHeaders({
"X-Entity-Ref-ID": nanoid(), emailId,
...(emailId headers,
? { "X-Usesend-Email-ID": emailId, "X-Unsend-Email-ID": emailId } unsubUrl,
: {}), isBulk,
...(unsubUrl inReplyToMessageId,
? { }),
"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>`,
}
: {}),
},
}); });
const chunks = []; const chunks = [];
@@ -19,6 +19,9 @@ export const emailSchema = z
bcc: z.string().or(z.array(z.string())).optional(), bcc: z.string().or(z.array(z.string())).optional(),
text: z.string().min(1).optional().nullable(), text: z.string().min(1).optional().nullable(),
html: z.coerce.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 attachments: z
.array( .array(
z.object({ z.object({
@@ -10,6 +10,7 @@ import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
import { logger } from "../logger/log"; import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
import { LimitService } from "./limit-service"; import { LimitService } from "./limit-service";
import { sanitizeCustomHeaders } from "~/server/utils/email-headers";
// Notifications about limits are handled inside LimitService. // Notifications about limits are handled inside LimitService.
type QueueEmailJob = TeamJob<{ type QueueEmailJob = TeamJob<{
@@ -127,7 +128,13 @@ export class EmailQueueService {
} }
queue.add( queue.add(
emailId, emailId,
{ emailId, timestamp: Date.now(), unsubUrl, isBulk, teamId }, {
emailId,
timestamp: Date.now(),
unsubUrl,
isBulk,
teamId,
},
{ jobId: emailId, delay, ...DEFAULT_QUEUE_OPTIONS } { jobId: emailId, delay, ...DEFAULT_QUEUE_OPTIONS }
); );
} }
@@ -390,6 +397,8 @@ async function executeEmail(job: QueueEmailJob) {
return; return;
} }
const customHeaders = email.headers ? JSON.parse(email.headers) : undefined;
const messageId = await sendRawEmail({ const messageId = await sendRawEmail({
to: email.to, to: email.to,
from: email.from, from: email.from,
@@ -407,6 +416,7 @@ async function executeEmail(job: QueueEmailJob) {
inReplyToMessageId, inReplyToMessageId,
emailId: email.id, emailId: email.id,
sesTenantId: domain?.sesTenantId, sesTenantId: domain?.sesTenantId,
headers: customHeaders,
}); });
logger.info( logger.info(
@@ -414,10 +424,10 @@ async function executeEmail(job: QueueEmailJob) {
`[EmailQueueService]: Email sent` `[EmailQueueService]: Email sent`
); );
// Delete attachments after sending the email // Delete attachments and headers after sending the email
await db.email.update({ await db.email.update({
where: { id: email.id }, where: { id: email.id },
data: { sesEmailId: messageId, text, attachments: null }, data: { sesEmailId: messageId, text, attachments: null, headers: null },
}); });
} catch (error: any) { } catch (error: any) {
await db.emailEvent.create({ await db.emailEvent.create({
+13 -2
View File
@@ -2,10 +2,15 @@ import { EmailContent } from "~/types";
import { db } from "../db"; import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error"; import { UnsendApiError } from "~/server/public-api/api-error";
import { EmailQueueService } from "./email-queue-service"; 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 { EmailRenderer } from "@usesend/email-editor/src/renderer";
import { logger } from "../logger/log"; import { logger } from "../logger/log";
import { SuppressionService } from "./suppression-service"; import { SuppressionService } from "./suppression-service";
import { sanitizeCustomHeaders } from "~/server/utils/email-headers";
import { Prisma } from "@prisma/client";
async function checkIfValidEmail(emailId: string) { async function checkIfValidEmail(emailId: string) {
const email = await db.email.findUnique({ const email = await db.email.findUnique({
@@ -66,6 +71,7 @@ export async function sendEmail(
scheduledAt, scheduledAt,
apiKeyId, apiKeyId,
inReplyToId, inReplyToId,
headers,
} = emailContent; } = emailContent;
let subject = subjectFromApiCall; let subject = subjectFromApiCall;
let html = htmlFromApiCall; let html = htmlFromApiCall;
@@ -261,6 +267,7 @@ export async function sendEmail(
latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED", latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED",
apiId: apiKeyId, apiId: apiKeyId,
inReplyToId, inReplyToId,
headers: headers ? JSON.stringify(headers) : undefined,
}, },
}); });
@@ -556,6 +563,9 @@ export async function sendBulkEmails(
latestStatus: "SUPPRESSED", latestStatus: "SUPPRESSED",
apiId: apiKeyId, apiId: apiKeyId,
inReplyToId, inReplyToId,
headers: originalContent.headers
? JSON.stringify(originalContent.headers)
: undefined,
}, },
}); });
@@ -628,6 +638,7 @@ export async function sendBulkEmails(
bcc, bcc,
scheduledAt, scheduledAt,
apiKeyId, apiKeyId,
headers,
} = content; } = content;
// Find the original index for this email // Find the original index for this email
@@ -691,7 +702,6 @@ export async function sendBulkEmails(
: undefined; : undefined;
try { try {
// Create email record
const email = await db.email.create({ const email = await db.email.create({
data: { data: {
to: Array.isArray(to) ? to : [to], to: Array.isArray(to) ? to : [to],
@@ -712,6 +722,7 @@ export async function sendBulkEmails(
scheduledAt: scheduledAtDate, scheduledAt: scheduledAtDate,
latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED", latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED",
apiId: apiKeyId, apiId: apiKeyId,
headers: headers ? JSON.stringify(headers) : undefined,
}, },
}); });
+121
View File
@@ -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<string, string | null | undefined>
): Record<string, string> | 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<string, string>
);
}
export function buildHeaders({
emailId,
headers,
unsubUrl,
isBulk,
inReplyToMessageId,
}: {
emailId?: string | undefined;
headers?: Record<string, string> | 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<string, string> = {};
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 ?? {}),
};
}
+1
View File
@@ -10,6 +10,7 @@ export type EmailContent = {
cc?: string | string[]; cc?: string | string[];
bcc?: string | string[]; bcc?: string | string[];
attachments?: Array<EmailAttachment>; attachments?: Array<EmailAttachment>;
headers?: Record<string, string>;
unsubUrl?: string; unsubUrl?: string;
scheduledAt?: string; scheduledAt?: string;
inReplyToId?: string | null; inReplyToId?: string | null;
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "usesend" name = "usesend"
version = "0.2.4" version = "0.2.5"
description = "Python SDK for the UseSend API" description = "Python SDK for the UseSend API"
authors = ["UseSend"] authors = ["UseSend"]
license = "MIT" license = "MIT"
+2
View File
@@ -215,6 +215,7 @@ EmailCreate = TypedDict(
'attachments': NotRequired[List[Attachment]], 'attachments': NotRequired[List[Attachment]],
'scheduledAt': NotRequired[Union[datetime, str]], 'scheduledAt': NotRequired[Union[datetime, str]],
'inReplyToId': NotRequired[str], 'inReplyToId': NotRequired[str],
'headers': NotRequired[Dict[str, str]],
} }
) )
@@ -239,6 +240,7 @@ EmailBatchItem = TypedDict(
'attachments': NotRequired[List[Attachment]], 'attachments': NotRequired[List[Attachment]],
'scheduledAt': NotRequired[Union[datetime, str]], 'scheduledAt': NotRequired[Union[datetime, str]],
'inReplyToId': NotRequired[str], 'inReplyToId': NotRequired[str],
'headers': NotRequired[Dict[str, str]],
} }
) )
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "usesend-js", "name": "usesend-js",
"version": "1.5.3", "version": "1.5.4",
"description": "", "description": "",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
+8
View File
@@ -535,6 +535,10 @@ export interface paths {
bcc?: string | string[]; bcc?: string | string[];
text?: string | null; text?: string | null;
html?: string | null; html?: string | null;
/** @description Custom headers to included with the emails */
headers?: {
[key: string]: string;
};
attachments?: { attachments?: {
filename: string; filename: string;
content: string; content: string;
@@ -598,6 +602,10 @@ export interface paths {
bcc?: string | string[]; bcc?: string | string[];
text?: string | null; text?: string | null;
html?: string | null; html?: string | null;
/** @description Custom headers to included with the emails */
headers?: {
[key: string]: string;
};
attachments?: { attachments?: {
filename: string; filename: string;
content: string; content: string;