feat: add custom email headers (#260)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -57,8 +57,13 @@ icon: node-js
|
||||
subject: "useSend email",
|
||||
html: "<p>useSend is the best open source product to send emails</p>",
|
||||
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>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -41,12 +41,15 @@ payload: types.EmailCreate = {
|
||||
"from": "no-reply@yourdomain.com",
|
||||
"subject": "Welcome",
|
||||
"html": "<strong>Hello!</strong>",
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Email" ADD COLUMN "headers" TEXT;
|
||||
@@ -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[]
|
||||
|
||||
|
||||
@@ -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<EmailContent> & {
|
||||
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 = [];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,6 +71,7 @@ export async function sendEmail(
|
||||
scheduledAt,
|
||||
apiKeyId,
|
||||
inReplyToId,
|
||||
headers,
|
||||
} = emailContent;
|
||||
let subject = subjectFromApiCall;
|
||||
let html = htmlFromApiCall;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ?? {}),
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type EmailContent = {
|
||||
cc?: string | string[];
|
||||
bcc?: string | string[];
|
||||
attachments?: Array<EmailAttachment>;
|
||||
headers?: Record<string, string>;
|
||||
unsubUrl?: string;
|
||||
scheduledAt?: string;
|
||||
inReplyToId?: string | null;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "usesend-js",
|
||||
"version": "1.5.3",
|
||||
"version": "1.5.4",
|
||||
"description": "",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user