Skip to content

Commit 890ad72

Browse files
authored
feat: add custom email headers (#260)
1 parent 1a00999 commit 890ad72

15 files changed

Lines changed: 202 additions & 30 deletions

File tree

apps/docs/api-reference/openapi.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,6 +1140,14 @@
11401140
"nullable": true,
11411141
"minLength": 1
11421142
},
1143+
"headers": {
1144+
"type": "object",
1145+
"additionalProperties": {
1146+
"type": "string",
1147+
"minLength": 1
1148+
},
1149+
"description": "Custom headers to included with the emails"
1150+
},
11431151
"attachments": {
11441152
"type": "array",
11451153
"items": {
@@ -1288,6 +1296,14 @@
12881296
"nullable": true,
12891297
"minLength": 1
12901298
},
1299+
"headers": {
1300+
"type": "object",
1301+
"additionalProperties": {
1302+
"type": "string",
1303+
"minLength": 1
1304+
},
1305+
"description": "Custom headers to included with the emails"
1306+
},
12911307
"attachments": {
12921308
"type": "array",
12931309
"items": {

apps/docs/get-started/nodejs.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,13 @@ icon: node-js
5757
subject: "useSend email",
5858
html: "<p>useSend is the best open source product to send emails</p>",
5959
text: "useSend is the best open source product to send emails",
60+
headers: {
61+
"X-Campaign": "welcome",
62+
},
6063
});
6164
```
65+
66+
> Custom headers are forwarded as-is. useSend only manages the `X-Usesend-Email-ID` and `References` headers.
6267
</Step>
6368
</Steps>
6469

apps/docs/get-started/python.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,15 @@ payload: types.EmailCreate = {
4141
"from": "no-reply@yourdomain.com",
4242
"subject": "Welcome",
4343
"html": "<strong>Hello!</strong>",
44+
"headers": {"X-Campaign": "welcome"},
4445
}
4546

4647
data, err = client.emails.send(payload)
4748
print(data or err)
4849
```
4950

51+
useSend forwards your custom headers to SES. Only the `X-Usesend-Email-ID` and `References` headers are managed automatically.
52+
5053
Attachments and scheduling:
5154

5255
```python
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Email" ADD COLUMN "headers" TEXT;

apps/web/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ model Email {
259259
campaignId String?
260260
contactId String?
261261
inReplyToId String?
262+
headers String?
262263
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
263264
emailEvents EmailEvent[]
264265

apps/web/src/server/aws/ses.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import { generateKeyPairSync } from "crypto";
1717
import nodemailer from "nodemailer";
1818
import { env } from "~/env";
1919
import { EmailContent } from "~/types";
20-
import { nanoid } from "../nanoid";
2120
import { logger } from "../logger/log";
21+
import { buildHeaders } from "~/server/utils/email-headers";
2222

2323
let accountId: string | undefined = undefined;
2424

@@ -201,6 +201,7 @@ export async function sendRawEmail({
201201
inReplyToMessageId,
202202
emailId,
203203
sesTenantId,
204+
headers,
204205
}: Partial<EmailContent> & {
205206
region: string;
206207
configurationSetName: string;
@@ -232,25 +233,13 @@ export async function sendRawEmail({
232233
replyTo,
233234
cc,
234235
bcc,
235-
headers: {
236-
"X-Entity-Ref-ID": nanoid(),
237-
...(emailId
238-
? { "X-Usesend-Email-ID": emailId, "X-Unsend-Email-ID": emailId }
239-
: {}),
240-
...(unsubUrl
241-
? {
242-
"List-Unsubscribe": `<${unsubUrl}>`,
243-
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
244-
}
245-
: {}),
246-
...(isBulk ? { Precedence: "bulk" } : {}),
247-
...(inReplyToMessageId
248-
? {
249-
"In-Reply-To": `<${inReplyToMessageId}@email.amazonses.com>`,
250-
References: `<${inReplyToMessageId}@email.amazonses.com>`,
251-
}
252-
: {}),
253-
},
236+
headers: buildHeaders({
237+
emailId,
238+
headers,
239+
unsubUrl,
240+
isBulk,
241+
inReplyToMessageId,
242+
}),
254243
});
255244

256245
const chunks = [];

apps/web/src/server/public-api/schemas/email-schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export const emailSchema = z
1919
bcc: z.string().or(z.array(z.string())).optional(),
2020
text: z.string().min(1).optional().nullable(),
2121
html: z.coerce.string().min(1).optional().nullable(),
22+
headers: z.record(z.string().min(1)).optional().openapi({
23+
description: "Custom headers to included with the emails",
24+
}),
2225
attachments: z
2326
.array(
2427
z.object({

apps/web/src/server/service/email-queue-service.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
1010
import { logger } from "../logger/log";
1111
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
1212
import { LimitService } from "./limit-service";
13+
import { sanitizeCustomHeaders } from "~/server/utils/email-headers";
1314
// Notifications about limits are handled inside LimitService.
1415

1516
type QueueEmailJob = TeamJob<{
@@ -127,7 +128,13 @@ export class EmailQueueService {
127128
}
128129
queue.add(
129130
emailId,
130-
{ emailId, timestamp: Date.now(), unsubUrl, isBulk, teamId },
131+
{
132+
emailId,
133+
timestamp: Date.now(),
134+
unsubUrl,
135+
isBulk,
136+
teamId,
137+
},
131138
{ jobId: emailId, delay, ...DEFAULT_QUEUE_OPTIONS }
132139
);
133140
}
@@ -390,6 +397,8 @@ async function executeEmail(job: QueueEmailJob) {
390397
return;
391398
}
392399

400+
const customHeaders = email.headers ? JSON.parse(email.headers) : undefined;
401+
393402
const messageId = await sendRawEmail({
394403
to: email.to,
395404
from: email.from,
@@ -407,17 +416,18 @@ async function executeEmail(job: QueueEmailJob) {
407416
inReplyToMessageId,
408417
emailId: email.id,
409418
sesTenantId: domain?.sesTenantId,
419+
headers: customHeaders,
410420
});
411421

412422
logger.info(
413423
{ emailId: email.id, sesEmailId: messageId },
414424
`[EmailQueueService]: Email sent`
415425
);
416426

417-
// Delete attachments after sending the email
427+
// Delete attachments and headers after sending the email
418428
await db.email.update({
419429
where: { id: email.id },
420-
data: { sesEmailId: messageId, text, attachments: null },
430+
data: { sesEmailId: messageId, text, attachments: null, headers: null },
421431
});
422432
} catch (error: any) {
423433
await db.emailEvent.create({

apps/web/src/server/service/email-service.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { EmailContent } from "~/types";
22
import { db } from "../db";
33
import { UnsendApiError } from "~/server/public-api/api-error";
44
import { EmailQueueService } from "./email-queue-service";
5-
import { validateDomainFromEmail, validateApiKeyDomainAccess } from "./domain-service";
5+
import {
6+
validateDomainFromEmail,
7+
validateApiKeyDomainAccess,
8+
} from "./domain-service";
69
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
710
import { logger } from "../logger/log";
811
import { SuppressionService } from "./suppression-service";
12+
import { sanitizeCustomHeaders } from "~/server/utils/email-headers";
13+
import { Prisma } from "@prisma/client";
914

1015
async function checkIfValidEmail(emailId: string) {
1116
const email = await db.email.findUnique({
@@ -66,26 +71,27 @@ export async function sendEmail(
6671
scheduledAt,
6772
apiKeyId,
6873
inReplyToId,
74+
headers,
6975
} = emailContent;
7076
let subject = subjectFromApiCall;
7177
let html = htmlFromApiCall;
7278

7379
let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
74-
80+
7581
// If this is an API call with an API key, validate domain access
7682
if (apiKeyId) {
7783
const apiKey = await db.apiKey.findUnique({
7884
where: { id: apiKeyId },
7985
include: { domain: true },
8086
});
81-
87+
8288
if (!apiKey) {
8389
throw new UnsendApiError({
8490
code: "BAD_REQUEST",
8591
message: "Invalid API key",
8692
});
8793
}
88-
94+
8995
domain = await validateApiKeyDomainAccess(from, teamId, apiKey);
9096
} else {
9197
// For non-API calls (dashboard, etc.), use regular domain validation
@@ -261,6 +267,7 @@ export async function sendEmail(
261267
latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED",
262268
apiId: apiKeyId,
263269
inReplyToId,
270+
headers: headers ? JSON.stringify(headers) : undefined,
264271
},
265272
});
266273

@@ -556,6 +563,9 @@ export async function sendBulkEmails(
556563
latestStatus: "SUPPRESSED",
557564
apiId: apiKeyId,
558565
inReplyToId,
566+
headers: originalContent.headers
567+
? JSON.stringify(originalContent.headers)
568+
: undefined,
559569
},
560570
});
561571

@@ -628,6 +638,7 @@ export async function sendBulkEmails(
628638
bcc,
629639
scheduledAt,
630640
apiKeyId,
641+
headers,
631642
} = content;
632643

633644
// Find the original index for this email
@@ -691,7 +702,6 @@ export async function sendBulkEmails(
691702
: undefined;
692703

693704
try {
694-
// Create email record
695705
const email = await db.email.create({
696706
data: {
697707
to: Array.isArray(to) ? to : [to],
@@ -712,6 +722,7 @@ export async function sendBulkEmails(
712722
scheduledAt: scheduledAtDate,
713723
latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED",
714724
apiId: apiKeyId,
725+
headers: headers ? JSON.stringify(headers) : undefined,
715726
},
716727
});
717728

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { nanoid } from "../nanoid";
2+
3+
const RESERVED_EMAIL_HEADERS = new Set(
4+
["x-usesend-email-id"].map((header) => header.toLowerCase())
5+
);
6+
7+
const HEADER_INJECTION_PATTERN = /[\r\n]/;
8+
9+
/**
10+
* Removes reserved headers and values that could result in header injection.
11+
* Returns `undefined` when the resulting object is empty so downstream callers
12+
* can skip persisting redundant data.
13+
*/
14+
export function sanitizeHeader(
15+
rawName: unknown,
16+
rawValue: unknown
17+
): { name: string; value: string } | undefined {
18+
if (typeof rawName !== "string" || typeof rawValue !== "string") {
19+
return undefined;
20+
}
21+
22+
const name = rawName.trim();
23+
if (!name || RESERVED_EMAIL_HEADERS.has(name.toLowerCase())) {
24+
return undefined;
25+
}
26+
27+
if (
28+
HEADER_INJECTION_PATTERN.test(name) ||
29+
HEADER_INJECTION_PATTERN.test(rawValue)
30+
) {
31+
return undefined;
32+
}
33+
34+
return { name, value: rawValue };
35+
}
36+
37+
export function sanitizeCustomHeaders(
38+
headers?: Record<string, string | null | undefined>
39+
): Record<string, string> | undefined {
40+
if (!headers) {
41+
return undefined;
42+
}
43+
44+
const sanitizedEntries = Object.entries(headers)
45+
.map(([name, value]) => sanitizeHeader(name, value))
46+
.filter((entry): entry is { name: string; value: string } =>
47+
Boolean(entry)
48+
);
49+
50+
if (sanitizedEntries.length === 0) {
51+
return undefined;
52+
}
53+
54+
return sanitizedEntries.reduce(
55+
(acc, { name, value }) => {
56+
acc[name] = value;
57+
return acc;
58+
},
59+
{} as Record<string, string>
60+
);
61+
}
62+
63+
export function buildHeaders({
64+
emailId,
65+
headers,
66+
unsubUrl,
67+
isBulk,
68+
inReplyToMessageId,
69+
}: {
70+
emailId?: string | undefined;
71+
headers?: Record<string, string> | undefined;
72+
unsubUrl?: string;
73+
isBulk?: boolean;
74+
inReplyToMessageId?: string | undefined;
75+
}) {
76+
const sanitizedHeaders = sanitizeCustomHeaders(headers);
77+
const sanitizedHeaderNames = new Set(
78+
Object.keys(sanitizedHeaders ?? {}).map((name) => name.toLowerCase())
79+
);
80+
81+
const defaultHeaders: Record<string, string> = {};
82+
83+
if (!sanitizedHeaderNames.has("x-entity-ref-id")) {
84+
defaultHeaders["X-Entity-Ref-ID"] = nanoid();
85+
}
86+
87+
if (emailId) {
88+
defaultHeaders["X-Usesend-Email-ID"] = emailId;
89+
}
90+
91+
if (unsubUrl) {
92+
if (!sanitizedHeaderNames.has("list-unsubscribe")) {
93+
defaultHeaders["List-Unsubscribe"] = `<${unsubUrl}>`;
94+
}
95+
96+
if (!sanitizedHeaderNames.has("list-unsubscribe-post")) {
97+
defaultHeaders["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
98+
}
99+
}
100+
101+
if (isBulk && !sanitizedHeaderNames.has("precedence")) {
102+
defaultHeaders["Precedence"] = "bulk";
103+
}
104+
105+
if (inReplyToMessageId) {
106+
const formattedMessageId = `<${inReplyToMessageId}@email.amazonses.com>`;
107+
108+
if (!sanitizedHeaderNames.has("in-reply-to")) {
109+
defaultHeaders["In-Reply-To"] = formattedMessageId;
110+
}
111+
112+
if (!sanitizedHeaderNames.has("references")) {
113+
defaultHeaders["References"] = formattedMessageId;
114+
}
115+
}
116+
117+
return {
118+
...defaultHeaders,
119+
...(sanitizedHeaders ?? {}),
120+
};
121+
}

0 commit comments

Comments
 (0)