add batch email api (#149)

* add bulk email

* add bulk email api

* add batch email sdk changes
This commit is contained in:
KM Koushik
2025-04-19 21:45:17 +10:00
committed by GitHub
parent 44e4f43e66
commit 3fe96b477f
10 changed files with 724 additions and 49 deletions

View File

@@ -243,9 +243,10 @@
{ {
"schema": { "schema": {
"type": "number", "type": "number",
"nullable": true,
"example": 1 "example": 1
}, },
"required": true, "required": false,
"name": "id", "name": "id",
"in": "path" "in": "path"
} }
@@ -494,21 +495,25 @@
"to": { "to": {
"anyOf": [ "anyOf": [
{ {
"type": "string" "type": "string",
"format": "email"
}, },
{ {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string",
"format": "email"
} }
} }
] ]
}, },
"from": { "from": {
"type": "string" "type": "string",
"format": "email"
}, },
"subject": { "subject": {
"type": "string", "type": "string",
"minLength": 1,
"description": "Optional when templateId is provided" "description": "Optional when templateId is provided"
}, },
"templateId": { "templateId": {
@@ -524,12 +529,14 @@
"replyTo": { "replyTo": {
"anyOf": [ "anyOf": [
{ {
"type": "string" "type": "string",
"format": "email"
}, },
{ {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string",
"format": "email"
} }
} }
] ]
@@ -537,12 +544,14 @@
"cc": { "cc": {
"anyOf": [ "anyOf": [
{ {
"type": "string" "type": "string",
"format": "email"
}, },
{ {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string",
"format": "email"
} }
} }
] ]
@@ -550,21 +559,27 @@
"bcc": { "bcc": {
"anyOf": [ "anyOf": [
{ {
"type": "string" "type": "string",
"format": "email"
}, },
{ {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string",
"format": "email"
} }
} }
] ]
}, },
"text": { "text": {
"type": "string" "type": "string",
"nullable": true,
"minLength": 1
}, },
"html": { "html": {
"type": "string" "type": "string",
"nullable": true,
"minLength": 1
}, },
"attachments": { "attachments": {
"type": "array", "type": "array",
@@ -572,17 +587,20 @@
"type": "object", "type": "object",
"properties": { "properties": {
"filename": { "filename": {
"type": "string" "type": "string",
"minLength": 1
}, },
"content": { "content": {
"type": "string" "type": "string",
"minLength": 1
} }
}, },
"required": [ "required": [
"filename", "filename",
"content" "content"
] ]
} },
"maxItems": 10
}, },
"scheduledAt": { "scheduledAt": {
"type": "string", "type": "string",
@@ -616,6 +634,175 @@
} }
} }
}, },
"/v1/emails/batch": {
"post": {
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"to": {
"anyOf": [
{
"type": "string",
"format": "email"
},
{
"type": "array",
"items": {
"type": "string",
"format": "email"
}
}
]
},
"from": {
"type": "string",
"format": "email"
},
"subject": {
"type": "string",
"minLength": 1,
"description": "Optional when templateId is provided"
},
"templateId": {
"type": "string",
"description": "ID of a template from the dashboard"
},
"variables": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"replyTo": {
"anyOf": [
{
"type": "string",
"format": "email"
},
{
"type": "array",
"items": {
"type": "string",
"format": "email"
}
}
]
},
"cc": {
"anyOf": [
{
"type": "string",
"format": "email"
},
{
"type": "array",
"items": {
"type": "string",
"format": "email"
}
}
]
},
"bcc": {
"anyOf": [
{
"type": "string",
"format": "email"
},
{
"type": "array",
"items": {
"type": "string",
"format": "email"
}
}
]
},
"text": {
"type": "string",
"nullable": true,
"minLength": 1
},
"html": {
"type": "string",
"nullable": true,
"minLength": 1
},
"attachments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"filename": {
"type": "string",
"minLength": 1
},
"content": {
"type": "string",
"minLength": 1
}
},
"required": [
"filename",
"content"
]
},
"maxItems": 10
},
"scheduledAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"to",
"from"
]
},
"maxItems": 100
}
}
}
},
"responses": {
"200": {
"description": "List of successfully created email IDs",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"emailId": {
"type": "string"
}
},
"required": [
"emailId"
]
}
}
},
"required": [
"data"
]
}
}
}
}
}
}
},
"/v1/emails/{emailId}/cancel": { "/v1/emails/{emailId}/cancel": {
"post": { "post": {
"parameters": [ "parameters": [

View File

@@ -0,0 +1,73 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { sendBulkEmails } from "~/server/service/email-service";
import { EmailContent } from "~/types";
import { emailSchema } from "../../schemas/email-schema"; // Corrected import path
// Define the schema for a single email within the bulk request
// This is similar to the schema in send-email.ts but without the top-level 'required'
// Removed inline emailSchema definition
const route = createRoute({
method: "post",
path: "/v1/emails/batch",
request: {
body: {
required: true,
content: {
"application/json": {
// Use the imported schema in an array
schema: z.array(emailSchema).max(100, {
message:
"Cannot send more than 100 emails in a single bulk request",
}),
},
},
},
},
responses: {
200: {
content: {
"application/json": {
// Return an array of objects with the created email IDs
schema: z.object({
data: z.array(z.object({ emailId: z.string() })),
}),
},
},
description: "List of successfully created email IDs",
},
// Add other potential error responses based on sendBulkEmails logic if needed
},
});
function sendBatch(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = await getTeamFromToken(c);
const emailPayloads = c.req.valid("json");
// Add teamId and apiKeyId to each email payload
const emailsToSend: Array<
EmailContent & { teamId: number; apiKeyId?: number }
> = emailPayloads.map((payload) => ({
...payload,
text: payload.text ?? undefined,
html: payload.html ?? undefined,
teamId: team.id,
apiKeyId: team.apiKeyId,
}));
// Call the service function to send emails in bulk
const createdEmails = await sendBulkEmails(emailsToSend);
// Map the result to the response format
const responseData = createdEmails.map((email) => ({
emailId: email.id,
}));
return c.json({ data: responseData });
});
}
export default sendBatch;

View File

@@ -2,6 +2,7 @@ import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono"; import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth"; import { getTeamFromToken } from "~/server/public-api/auth";
import { sendEmail } from "~/server/service/email-service"; import { sendEmail } from "~/server/service/email-service";
import { emailSchema } from "../../schemas/email-schema";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
@@ -11,36 +12,7 @@ const route = createRoute({
required: true, required: true,
content: { content: {
"application/json": { "application/json": {
schema: z schema: emailSchema,
.object({
to: z.string().or(z.array(z.string())),
from: z.string(),
subject: z.string().optional().openapi({
description: "Optional when templateId is provided",
}),
templateId: z.string().optional().openapi({
description: "ID of a template from the dashboard",
}),
variables: z.record(z.string()).optional(),
replyTo: z.string().or(z.array(z.string())).optional(),
cc: z.string().or(z.array(z.string())).optional(),
bcc: z.string().or(z.array(z.string())).optional(),
text: z.string().optional().nullable(),
html: z.string().optional().nullable(),
attachments: z
.array(
z.object({
filename: z.string(),
content: z.string(),
})
)
.optional(),
scheduledAt: z.string().datetime().optional(),
})
.refine(
(data) => !!data.subject || !!data.templateId,
"Either subject or templateId should be passed."
),
}, },
}, },
}, },

View File

@@ -12,6 +12,7 @@ import upsertContact from "./api/contacts/upsert-contact";
import createDomain from "./api/domains/create-domain"; import createDomain from "./api/domains/create-domain";
import deleteContact from "./api/contacts/delete-contact"; import deleteContact from "./api/contacts/delete-contact";
import verifyDomain from "./api/domains/verify-domain"; import verifyDomain from "./api/domains/verify-domain";
import sendBatch from "./api/emails/batch-email";
export const app = getApp(); export const app = getApp();
@@ -23,6 +24,7 @@ verifyDomain(app);
/**Email related APIs */ /**Email related APIs */
getEmail(app); getEmail(app);
sendEmail(app); sendEmail(app);
sendBatch(app);
updateEmailScheduledAt(app); updateEmailScheduledAt(app);
cancelScheduledEmail(app); cancelScheduledEmail(app);

View File

@@ -0,0 +1,40 @@
import { z } from "@hono/zod-openapi";
/**
* Reusable Zod schema for a single email payload used in public API requests.
*/
export const emailSchema = z
.object({
to: z.string().email().or(z.array(z.string().email())),
from: z.string().email(),
subject: z.string().min(1).optional().openapi({
description: "Optional when templateId is provided",
}),
templateId: z.string().optional().openapi({
description: "ID of a template from the dashboard",
}),
variables: z.record(z.string()).optional(),
replyTo: z.string().email().or(z.array(z.string().email())).optional(),
cc: z.string().email().or(z.array(z.string().email())).optional(),
bcc: z.string().email().or(z.array(z.string().email())).optional(),
text: z.string().min(1).optional().nullable(),
html: z.string().min(1).optional().nullable(),
attachments: z
.array(
z.object({
filename: z.string().min(1),
content: z.string().min(1), // Consider base64 validation if needed
})
)
.max(10) // Limit attachments array size if desired
.optional(),
scheduledAt: z.string().datetime({ offset: true }).optional(), // Ensure ISO 8601 format with offset
})
.refine(
(data) => !!data.subject || !!data.templateId,
"Either subject or templateId must be provided."
)
.refine(
(data) => !!data.text || !!data.html,
"Either text or html content must be provided."
);

View File

@@ -7,6 +7,7 @@ import { db } from "../db";
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses"; import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
import { getRedis } from "../redis"; import { getRedis } from "../redis";
import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
import { Prisma } from "@prisma/client";
function createQueueAndWorker(region: string, quota: number, suffix: string) { function createQueueAndWorker(region: string, quota: number, suffix: string) {
const connection = getRedis(); const connection = getRedis();
@@ -112,6 +113,113 @@ export class EmailQueueService {
); );
} }
/**
* Efficiently queues multiple pre-defined email jobs using BullMQ's addBulk.
* Jobs are grouped by region and type (transactional/marketing) before queuing.
*
* @param jobs - Array of job details to queue.
* @returns A promise that resolves when all bulk additions are attempted.
*/
public static async queueBulk(
jobs: {
emailId: string;
region: string;
transactional: boolean;
unsubUrl?: string;
delay?: number;
timestamp?: number; // Optional: pass timestamp if needed for data
}[]
): Promise<void> {
if (jobs.length === 0) {
console.log("[EmailQueueService]: No jobs provided for bulk queue.");
return;
}
if (!this.initialized) {
await this.init();
}
console.log(
`[EmailQueueService]: Starting bulk queue for ${jobs.length} jobs.`
);
// Group jobs by region and type
const groupedJobs = jobs.reduce(
(acc, job) => {
const key = `${job.region}-${job.transactional ? "transactional" : "marketing"}`;
if (!acc[key]) {
acc[key] = {
queue: job.transactional
? this.transactionalQueue.get(job.region)
: this.marketingQueue.get(job.region),
region: job.region,
transactional: job.transactional,
jobDetails: [],
};
}
acc[key]?.jobDetails.push(job);
return acc;
},
{} as Record<
string,
{
queue: Queue | undefined;
region: string;
transactional: boolean;
jobDetails: typeof jobs;
}
>
);
const bulkAddPromises: Promise<any>[] = [];
for (const groupKey in groupedJobs) {
const group = groupedJobs[groupKey];
if (!group || !group.queue) {
console.error(
`[EmailQueueService]: Queue not found for group ${groupKey} during bulk add. Skipping ${group?.jobDetails?.length ?? 0} jobs.`
);
// Optionally: handle these skipped jobs (e.g., mark corresponding emails as failed)
continue;
}
const queue = group.queue;
const isBulk = !group.transactional;
const bulkData = group.jobDetails.map((job) => ({
name: job.emailId, // Use emailId as job name (matches single queue logic)
data: {
emailId: job.emailId,
timestamp: job.timestamp ?? Date.now(),
unsubUrl: job.unsubUrl,
isBulk,
},
opts: {
jobId: job.emailId, // Use emailId as jobId
delay: job.delay,
...DEFAULT_QUEUE_OPTIONS, // Apply default options (attempts, backoff)
},
}));
console.log(
`[EmailQueueService]: Adding ${bulkData.length} jobs to queue ${queue.name}`
);
bulkAddPromises.push(
queue.addBulk(bulkData).catch((error) => {
console.error(
`[EmailQueueService]: Failed to add bulk jobs to queue ${queue.name}:`,
error
);
// Optionally: handle bulk add failure (e.g., mark corresponding emails as failed)
})
);
}
await Promise.allSettled(bulkAddPromises);
console.log(
"[EmailQueueService]: Finished processing bulk queue requests."
);
}
public static async changeDelay( public static async changeDelay(
emailId: string, emailId: string,
region: string, region: string,

View File

@@ -220,3 +220,213 @@ export async function cancelEmail(emailId: string) {
}, },
}); });
} }
/**
* Send multiple emails in bulk (up to 100 at a time)
* Handles template rendering, variable replacement, and efficient bulk queuing
*/
export async function sendBulkEmails(
emailContents: Array<
EmailContent & {
teamId: number;
apiKeyId?: number;
}
>
) {
if (emailContents.length === 0) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "No emails provided for bulk send",
});
}
if (emailContents.length > 100) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Cannot send more than 100 emails in a single bulk request",
});
}
// Group emails by domain to minimize domain validations
const emailsByDomain = new Map<
string,
{
domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
emails: typeof emailContents;
}
>();
// First pass: validate domains and group emails
for (const content of emailContents) {
const { from } = content;
if (!emailsByDomain.has(from)) {
const domain = await validateDomainFromEmail(from, content.teamId);
emailsByDomain.set(from, { domain, emails: [] });
}
emailsByDomain.get(from)?.emails.push(content);
}
// Cache templates to avoid repeated database queries
const templateCache = new Map<
number,
{ subject: string; content: any; renderer: EmailRenderer }
>();
const createdEmails = [];
const queueJobs = [];
// Process each domain group
for (const { domain, emails } of emailsByDomain.values()) {
// Process emails in each domain group
for (const content of emails) {
const {
to,
from,
subject: subjectFromApiCall,
templateId,
variables,
text,
html: htmlFromApiCall,
teamId,
attachments,
replyTo,
cc,
bcc,
scheduledAt,
apiKeyId,
} = content;
let subject = subjectFromApiCall;
let html = htmlFromApiCall;
// Process template if specified
if (templateId) {
let templateData = templateCache.get(Number(templateId));
if (!templateData) {
const template = await db.template.findUnique({
where: { id: templateId },
});
if (template) {
const jsonContent = JSON.parse(template.content || "{}");
templateData = {
subject: template.subject || "",
content: jsonContent,
renderer: new EmailRenderer(jsonContent),
};
templateCache.set(Number(templateId), templateData);
}
}
if (templateData) {
subject = replaceVariables(templateData.subject, variables || {});
// {{}} for link replacements
const modifiedVariables = {
...variables,
...Object.keys(variables || {}).reduce(
(acc, key) => {
acc[`{{${key}}}`] = variables?.[key] || "";
return acc;
},
{} as Record<string, string>
),
};
html = await templateData.renderer.render({
shouldReplaceVariableValues: true,
variableValues: modifiedVariables,
});
}
}
if (!text && !html) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: `Either text or html is required for email to ${to}`,
});
}
const scheduledAtDate = scheduledAt ? new Date(scheduledAt) : undefined;
const delay = scheduledAtDate
? Math.max(0, scheduledAtDate.getTime() - Date.now())
: undefined;
try {
// Create email record
const email = await db.email.create({
data: {
to: Array.isArray(to) ? to : [to],
from,
subject: subject as string,
replyTo: replyTo
? Array.isArray(replyTo)
? replyTo
: [replyTo]
: undefined,
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
text,
html,
teamId,
domainId: domain.id,
attachments: attachments ? JSON.stringify(attachments) : undefined,
scheduledAt: scheduledAtDate,
latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED",
apiId: apiKeyId,
},
});
createdEmails.push(email);
// Prepare queue job
queueJobs.push({
emailId: email.id,
region: domain.region,
transactional: true, // Bulk emails are still transactional
delay,
timestamp: Date.now(),
});
} catch (error: any) {
console.error(
`Failed to create email record for recipient ${to}:`,
error
);
// Continue processing other emails
}
}
}
if (queueJobs.length === 0) {
throw new UnsendApiError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create any email records",
});
}
// Bulk queue all jobs
try {
await EmailQueueService.queueBulk(queueJobs);
} catch (error: any) {
// Mark all created emails as failed
await Promise.all(
createdEmails.map(async (email) => {
await db.emailEvent.create({
data: {
emailId: email.id,
status: "FAILED",
data: {
error: error.toString(),
},
},
});
await db.email.update({
where: { id: email.id },
data: { latestStatus: "FAILED" },
});
})
);
throw error;
}
return createdEmails;
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "unsend", "name": "unsend",
"version": "1.4.2", "version": "1.5.0",
"description": "", "description": "",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",

View File

@@ -46,6 +46,27 @@ type CancelEmailResponse = {
type CancelEmailResponseSuccess = type CancelEmailResponseSuccess =
paths["/v1/emails/{emailId}/cancel"]["post"]["responses"]["200"]["content"]["application/json"]; paths["/v1/emails/{emailId}/cancel"]["post"]["responses"]["200"]["content"]["application/json"];
// Batch emails types
/**
* Payload for sending multiple emails in a single batch request.
*/
type BatchEmailPayload =
paths["/v1/emails/batch"]["post"]["requestBody"]["content"]["application/json"];
/**
* Successful response schema for batch email send.
*/
type BatchEmailResponseSuccess =
paths["/v1/emails/batch"]["post"]["responses"]["200"]["content"]["application/json"];
/**
* Response structure for the batch method.
*/
type BatchEmailResponse = {
data: BatchEmailResponseSuccess["data"] | null;
error: ErrorResponse | null;
};
export class Emails { export class Emails {
constructor(private readonly unsend: Unsend) { constructor(private readonly unsend: Unsend) {
this.unsend = unsend; this.unsend = unsend;
@@ -69,6 +90,24 @@ export class Emails {
return data; return data;
} }
/**
* Send up to 100 emails in a single request.
*
* @param payload An array of email payloads. Max 100 emails.
* @returns A promise that resolves to the list of created email IDs or an error.
*/
async batch(payload: BatchEmailPayload): Promise<BatchEmailResponse> {
// Note: React element rendering is not supported in batch mode.
const response = await this.unsend.post<BatchEmailResponseSuccess>(
"/emails/batch",
payload
);
return {
data: response.data ? response.data.data : null,
error: response.error,
};
}
async get(id: string): Promise<GetEmailResponse> { async get(id: string): Promise<GetEmailResponse> {
const data = await this.unsend.get<GetEmailResponseSuccess>( const data = await this.unsend.get<GetEmailResponseSuccess>(
`/emails/${id}` `/emails/${id}`

View File

@@ -109,7 +109,7 @@ export interface paths {
put: { put: {
parameters: { parameters: {
path: { path: {
id: number; id: number | null;
}; };
}; };
responses: { responses: {
@@ -192,6 +192,7 @@ export interface paths {
content: { content: {
"application/json": { "application/json": {
to: string | string[]; to: string | string[];
/** Format: email */
from: string; from: string;
/** @description Optional when templateId is provided */ /** @description Optional when templateId is provided */
subject?: string; subject?: string;
@@ -203,8 +204,8 @@ export interface paths {
replyTo?: string | string[]; replyTo?: string | string[];
cc?: string | string[]; cc?: string | string[];
bcc?: string | string[]; bcc?: string | string[];
text?: string; text?: string | null;
html?: string; html?: string | null;
attachments?: { attachments?: {
filename: string; filename: string;
content: string; content: string;
@@ -226,6 +227,49 @@ export interface paths {
}; };
}; };
}; };
"/v1/emails/batch": {
post: {
requestBody: {
content: {
"application/json": ({
to: string | string[];
/** Format: email */
from: string;
/** @description Optional when templateId is provided */
subject?: string;
/** @description ID of a template from the dashboard */
templateId?: string;
variables?: {
[key: string]: string;
};
replyTo?: string | string[];
cc?: string | string[];
bcc?: string | string[];
text?: string | null;
html?: string | null;
attachments?: {
filename: string;
content: string;
}[];
/** Format: date-time */
scheduledAt?: string;
})[];
};
};
responses: {
/** @description List of successfully created email IDs */
200: {
content: {
"application/json": {
data: {
emailId: string;
}[];
};
};
};
};
};
};
"/v1/emails/{emailId}/cancel": { "/v1/emails/{emailId}/cancel": {
post: { post: {
parameters: { parameters: {