"
onBlur={() => {
+ if (isApiCampaign) {
+ return;
+ }
if (from === campaign.from || !from) {
return;
}
@@ -279,6 +294,8 @@ function CampaignEditor({
}
);
}}
+ disabled={isApiCampaign}
+ readOnly={isApiCampaign}
/>
@@ -294,6 +311,9 @@ function CampaignEditor({
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
placeholder="hello@example.com"
onBlur={() => {
+ if (isApiCampaign) {
+ return;
+ }
if (replyTo === campaign.replyTo[0]) {
return;
}
@@ -310,6 +330,8 @@ function CampaignEditor({
}
);
}}
+ disabled={isApiCampaign}
+ readOnly={isApiCampaign}
/>
@@ -324,6 +346,9 @@ function CampaignEditor({
setPreviewText(e.target.value);
}}
onBlur={() => {
+ if (isApiCampaign) {
+ return;
+ }
if (
previewText === campaign.previewText ||
!previewText
@@ -344,6 +369,8 @@ function CampaignEditor({
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
+ disabled={isApiCampaign}
+ readOnly={isApiCampaign}
/>
@@ -355,7 +382,11 @@ function CampaignEditor({
) : (
);
diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts
index 6f1cac24..bc81e3f9 100644
--- a/apps/web/src/server/api/routers/campaign.ts
+++ b/apps/web/src/server/api/routers/campaign.ts
@@ -119,12 +119,14 @@ export const campaignRouter = createTRPCRouter({
subject: z.string().optional(),
previewText: z.string().optional(),
content: z.string().optional(),
+ html: z.string().optional(),
contactBookId: z.string().optional(),
replyTo: z.string().array().optional(),
})
)
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
- const { campaignId, ...data } = input;
+ const { html: htmlInput, ...data } = input;
+ const campaignId = campaignOld.id;
if (data.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: data.contactBookId },
@@ -143,22 +145,29 @@ export const campaignRouter = createTRPCRouter({
domainId = domain.id;
}
- let html: string | null = null;
+ let htmlToSave: string | undefined;
if (data.content) {
const jsonContent = data.content ? JSON.parse(data.content) : null;
const renderer = new EmailRenderer(jsonContent);
- html = await renderer.render();
+ htmlToSave = await renderer.render();
+ } else if (typeof htmlInput === "string") {
+ htmlToSave = htmlInput;
+ }
+
+ const campaignUpdateData: Prisma.CampaignUpdateInput = {
+ ...data,
+ domainId,
+ };
+
+ if (htmlToSave !== undefined) {
+ campaignUpdateData.html = htmlToSave;
}
const campaign = await db.campaign.update({
where: { id: campaignId },
- data: {
- ...data,
- html,
- domainId,
- },
+ data: campaignUpdateData,
});
return campaign;
}),
@@ -201,10 +210,7 @@ export const campaignRouter = createTRPCRouter({
teamId: team.id,
campaignId: campaign.id,
},
- orderBy: [
- { updatedAt: "desc" },
- { createdAt: "desc" },
- ],
+ orderBy: [{ updatedAt: "desc" }, { createdAt: "desc" }],
take: 10,
select: {
id: true,
@@ -240,6 +246,7 @@ export const campaignRouter = createTRPCRouter({
from: campaign.from,
subject: campaign.subject,
content: campaign.content,
+ html: campaign.html,
teamId: team.id,
domainId: campaign.domainId,
contactBookId: campaign.contactBookId,
diff --git a/apps/web/src/server/public-api/api/campaigns/create-campaign.ts b/apps/web/src/server/public-api/api/campaigns/create-campaign.ts
new file mode 100644
index 00000000..6b056d31
--- /dev/null
+++ b/apps/web/src/server/public-api/api/campaigns/create-campaign.ts
@@ -0,0 +1,82 @@
+import { createRoute, z } from "@hono/zod-openapi";
+import { PublicAPIApp } from "~/server/public-api/hono";
+import {
+ campaignCreateSchema,
+ CampaignCreateInput,
+ campaignResponseSchema,
+ parseScheduledAt,
+} from "~/server/public-api/schemas/campaign-schema";
+import {
+ createCampaignFromApi,
+ getCampaignForTeam,
+ scheduleCampaign,
+} from "~/server/service/campaign-service";
+const route = createRoute({
+ method: "post",
+ path: "/v1/campaigns",
+ request: {
+ body: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: campaignCreateSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: "Create a campaign",
+ content: {
+ "application/json": {
+ schema: campaignResponseSchema,
+ },
+ },
+ },
+ },
+});
+
+function createCampaign(app: PublicAPIApp) {
+ app.openapi(route, async (c) => {
+ const team = c.var.team;
+ const body: CampaignCreateInput = c.req.valid("json");
+
+ const campaign = await createCampaignFromApi({
+ teamId: team.id,
+ apiKeyId: team.apiKeyId,
+ name: body.name,
+ from: body.from,
+ subject: body.subject,
+ previewText: body.previewText,
+ content: body.content,
+ html: body.html,
+ contactBookId: body.contactBookId,
+ replyTo: body.replyTo,
+ cc: body.cc,
+ bcc: body.bcc,
+ batchSize: body.batchSize,
+ });
+
+ if (body.sendNow || body.scheduledAt) {
+ const scheduledAtInput = body.sendNow
+ ? new Date()
+ : parseScheduledAt(body.scheduledAt);
+
+ await scheduleCampaign({
+ campaignId: campaign.id,
+ teamId: team.id,
+ scheduledAt: scheduledAtInput,
+ batchSize: body.batchSize,
+ });
+ }
+
+ const latestCampaign = await getCampaignForTeam({
+ campaignId: campaign.id,
+ teamId: team.id,
+ });
+
+ return c.json(latestCampaign);
+ });
+}
+
+export default createCampaign;
diff --git a/apps/web/src/server/public-api/api/campaigns/get-campaign.ts b/apps/web/src/server/public-api/api/campaigns/get-campaign.ts
new file mode 100644
index 00000000..f9bb5c34
--- /dev/null
+++ b/apps/web/src/server/public-api/api/campaigns/get-campaign.ts
@@ -0,0 +1,49 @@
+import { createRoute, z } from "@hono/zod-openapi";
+import { PublicAPIApp } from "~/server/public-api/hono";
+import { getCampaignForTeam } from "~/server/service/campaign-service";
+import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema";
+
+const route = createRoute({
+ method: "get",
+ path: "/v1/campaigns/{campaignId}",
+ request: {
+ params: z.object({
+ campaignId: z
+ .string()
+ .min(1)
+ .openapi({
+ param: {
+ name: "campaignId",
+ in: "path",
+ },
+ example: "cmp_123",
+ }),
+ }),
+ },
+ responses: {
+ 200: {
+ description: "Get campaign details",
+ content: {
+ "application/json": {
+ schema: campaignResponseSchema,
+ },
+ },
+ },
+ },
+});
+
+function getCampaign(app: PublicAPIApp) {
+ app.openapi(route, async (c) => {
+ const team = c.var.team;
+ const campaignId = c.req.param("campaignId");
+
+ const campaign = await getCampaignForTeam({
+ campaignId,
+ teamId: team.id,
+ });
+
+ return c.json(campaign);
+ });
+}
+
+export default getCampaign;
diff --git a/apps/web/src/server/public-api/api/campaigns/pause-campaign.ts b/apps/web/src/server/public-api/api/campaigns/pause-campaign.ts
new file mode 100644
index 00000000..173b65c2
--- /dev/null
+++ b/apps/web/src/server/public-api/api/campaigns/pause-campaign.ts
@@ -0,0 +1,54 @@
+import { createRoute, z } from "@hono/zod-openapi";
+import { PublicAPIApp } from "~/server/public-api/hono";
+import {
+ getCampaignForTeam,
+ pauseCampaign as pauseCampaignService,
+} from "~/server/service/campaign-service";
+import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema";
+
+const route = createRoute({
+ method: "post",
+ path: "/v1/campaigns/{campaignId}/pause",
+ request: {
+ params: z.object({
+ campaignId: z
+ .string()
+ .min(1)
+ .openapi({
+ param: {
+ name: "campaignId",
+ in: "path",
+ },
+ example: "cmp_123",
+ }),
+ }),
+ },
+ responses: {
+ 200: {
+ description: "Pause a campaign",
+ content: {
+ "application/json": {
+ schema: z.object({
+ success: z.boolean(),
+ }),
+ },
+ },
+ },
+ },
+});
+
+function pauseCampaign(app: PublicAPIApp) {
+ app.openapi(route, async (c) => {
+ const team = c.var.team;
+ const campaignId = c.req.param("campaignId");
+
+ await pauseCampaignService({
+ campaignId,
+ teamId: team.id,
+ });
+
+ return c.json({ success: true });
+ });
+}
+
+export default pauseCampaign;
diff --git a/apps/web/src/server/public-api/api/campaigns/resume-campaign.ts b/apps/web/src/server/public-api/api/campaigns/resume-campaign.ts
new file mode 100644
index 00000000..64743ab5
--- /dev/null
+++ b/apps/web/src/server/public-api/api/campaigns/resume-campaign.ts
@@ -0,0 +1,62 @@
+import { createRoute, z } from "@hono/zod-openapi";
+import { PublicAPIApp } from "~/server/public-api/hono";
+import {
+ getCampaignForTeam,
+ resumeCampaign as resumeCampaignService,
+} from "~/server/service/campaign-service";
+import {
+ campaignResponseSchema,
+ parseScheduledAt,
+} from "~/server/public-api/schemas/campaign-schema";
+
+const route = createRoute({
+ method: "post",
+ path: "/v1/campaigns/{campaignId}/resume",
+ request: {
+ params: z.object({
+ campaignId: z
+ .string()
+ .min(1)
+ .openapi({
+ param: {
+ name: "campaignId",
+ in: "path",
+ },
+ example: "cmp_123",
+ }),
+ }),
+ },
+ responses: {
+ 200: {
+ description: "Resume a campaign",
+ content: {
+ "application/json": {
+ schema: z.object({
+ success: z.boolean(),
+ }),
+ },
+ },
+ },
+ },
+});
+
+function resumeCampaign(app: PublicAPIApp) {
+ app.openapi(route, async (c) => {
+ const team = c.var.team;
+ const campaignId = c.req.param("campaignId");
+
+ await resumeCampaignService({
+ campaignId,
+ teamId: team.id,
+ });
+
+ await getCampaignForTeam({
+ campaignId,
+ teamId: team.id,
+ });
+
+ return c.json({ success: true });
+ });
+}
+
+export default resumeCampaign;
diff --git a/apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts b/apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts
new file mode 100644
index 00000000..6e9e5ac0
--- /dev/null
+++ b/apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts
@@ -0,0 +1,69 @@
+import { createRoute, z } from "@hono/zod-openapi";
+import { PublicAPIApp } from "~/server/public-api/hono";
+import {
+ campaignScheduleSchema,
+ CampaignScheduleInput,
+ campaignResponseSchema,
+ parseScheduledAt,
+} from "~/server/public-api/schemas/campaign-schema";
+import {
+ getCampaignForTeam,
+ scheduleCampaign as scheduleCampaignService,
+} from "~/server/service/campaign-service";
+const route = createRoute({
+ method: "post",
+ path: "/v1/campaigns/{campaignId}/schedule",
+ request: {
+ params: z.object({
+ campaignId: z
+ .string()
+ .min(1)
+ .openapi({
+ param: {
+ name: "campaignId",
+ in: "path",
+ },
+ example: "cmp_123",
+ }),
+ }),
+ body: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: campaignScheduleSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: "Schedule a campaign",
+ content: {
+ "application/json": {
+ schema: z.object({
+ success: z.boolean(),
+ }),
+ },
+ },
+ },
+ },
+});
+
+function scheduleCampaign(app: PublicAPIApp) {
+ app.openapi(route, async (c) => {
+ const team = c.var.team;
+ const campaignId = c.req.param("campaignId");
+ const body: CampaignScheduleInput = c.req.valid("json");
+
+ await scheduleCampaignService({
+ campaignId,
+ teamId: team.id,
+ scheduledAt: parseScheduledAt(body.scheduledAt),
+ batchSize: body.batchSize,
+ });
+
+ return c.json({ success: true });
+ });
+}
+
+export default scheduleCampaign;
diff --git a/apps/web/src/server/public-api/index.ts b/apps/web/src/server/public-api/index.ts
index 6cfe1de7..96eacf54 100644
--- a/apps/web/src/server/public-api/index.ts
+++ b/apps/web/src/server/public-api/index.ts
@@ -16,6 +16,11 @@ import verifyDomain from "./api/domains/verify-domain";
import getDomain from "./api/domains/get-domain";
import deleteDomain from "./api/domains/delete-domain";
import sendBatch from "./api/emails/batch-email";
+import createCampaign from "./api/campaigns/create-campaign";
+import getCampaign from "./api/campaigns/get-campaign";
+import scheduleCampaign from "./api/campaigns/schedule-campaign";
+import pauseCampaign from "./api/campaigns/pause-campaign";
+import resumeCampaign from "./api/campaigns/resume-campaign";
export const app = getApp();
@@ -42,4 +47,11 @@ getContacts(app);
upsertContact(app);
deleteContact(app);
+/**Campaign related APIs */
+createCampaign(app);
+getCampaign(app);
+scheduleCampaign(app);
+pauseCampaign(app);
+resumeCampaign(app);
+
export default app;
diff --git a/apps/web/src/server/public-api/schemas/campaign-schema.ts b/apps/web/src/server/public-api/schemas/campaign-schema.ts
new file mode 100644
index 00000000..5ba10000
--- /dev/null
+++ b/apps/web/src/server/public-api/schemas/campaign-schema.ts
@@ -0,0 +1,99 @@
+import { z } from "@hono/zod-openapi";
+import * as chrono from "chrono-node";
+import { UnsendApiError } from "../api-error";
+
+const stringOrStringArray = z.union([
+ z.string().min(1),
+ z.array(z.string().min(1)),
+]);
+
+export const parseScheduledAt = (scheduledAt?: string): Date | undefined => {
+ if (!scheduledAt) return undefined;
+
+ // Try parsing as ISO date first
+ const isoDate = new Date(scheduledAt);
+ if (!isNaN(isoDate.getTime())) {
+ return isoDate;
+ }
+
+ // Try parsing with chrono for natural language
+ const chronoDate = chrono.parseDate(scheduledAt);
+ if (chronoDate) {
+ return chronoDate;
+ }
+
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: `Invalid date format: ${scheduledAt}. Use ISO 8601 format or natural language like 'tomorrow 9am'.`,
+ });
+};
+
+export const campaignCreateSchema = z
+ .object({
+ name: z.string().min(1),
+ from: z.string().min(1),
+ subject: z.string().min(1),
+ previewText: z.string().optional(),
+ contactBookId: z.string().min(1),
+ content: z.string().min(1).optional(),
+ html: z.string().min(1).optional(),
+ replyTo: stringOrStringArray.optional(),
+ cc: stringOrStringArray.optional(),
+ bcc: stringOrStringArray.optional(),
+ sendNow: z.boolean().optional(),
+ scheduledAt: z
+ .string()
+ .optional()
+ .describe(
+ "Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am', 'next monday 10:30')"
+ ),
+ batchSize: z.number().int().min(1).max(100_000).optional(),
+ })
+ .refine(
+ (data) => !!data.content || !!data.html,
+ "Either content or html must be provided."
+ );
+
+export const campaignScheduleSchema = z.object({
+ scheduledAt: z
+ .string()
+ .optional()
+ .describe(
+ "Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am', 'next monday 10:30')"
+ ),
+ batchSize: z.number().int().min(1).max(100_000).optional(),
+});
+
+export type CampaignCreateInput = z.infer;
+export type CampaignScheduleInput = z.infer;
+
+export const campaignResponseSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ from: z.string(),
+ subject: z.string(),
+ previewText: z.string().nullable(),
+ contactBookId: z.string().nullable(),
+ html: z.string().nullable(),
+ content: z.string().nullable(),
+ status: z.string(),
+ scheduledAt: z.string().datetime().nullable(),
+ batchSize: z.number().int(),
+ batchWindowMinutes: z.number().int(),
+ total: z.number().int(),
+ sent: z.number().int(),
+ delivered: z.number().int(),
+ opened: z.number().int(),
+ clicked: z.number().int(),
+ unsubscribed: z.number().int(),
+ bounced: z.number().int(),
+ hardBounced: z.number().int(),
+ complained: z.number().int(),
+ replyTo: z.array(z.string()),
+ cc: z.array(z.string()),
+ bcc: z.array(z.string()),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+});
+
+export type CampaignResponse = z.infer;
diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts
index 387321a8..5ce49bfe 100644
--- a/apps/web/src/server/service/campaign-service.ts
+++ b/apps/web/src/server/service/campaign-service.ts
@@ -19,52 +19,339 @@ import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
import { SuppressionService } from "./suppression-service";
import { UnsendApiError } from "../public-api/api-error";
+import {
+ validateApiKeyDomainAccess,
+ validateDomainFromEmail,
+} from "./domain-service";
const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [
"{{unsend_unsubscribe_url}}",
"{{usesend_unsubscribe_url}}",
-];
+] as const;
-export async function sendCampaign(id: string) {
- let campaign = await db.campaign.findUnique({
- where: { id },
+const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES =
+ CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.map((placeholder) => {
+ const inner = placeholder.replace(/[{}]/g, "").trim();
+ return new RegExp(`\\{\\{\\s*${inner}\\s*\\}}`, "i");
});
- if (!campaign) {
- throw new Error("Campaign not found");
+const CONTACT_VARIABLE_REGEX =
+ /\{\{\s*(?:contact\.)?(email|firstName|lastName)(?:,fallback=([^}]+))?\s*\}\}/gi;
+
+function campaignHasUnsubscribePlaceholder(
+ ...sources: Array
+) {
+ return CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES.some((regex) =>
+ sources.some((source) => (source ? regex.test(source) : false))
+ );
+}
+
+function replaceUnsubscribePlaceholders(html: string, url: string) {
+ return CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES.reduce((acc, regex) => {
+ return acc.replace(new RegExp(regex.source, "gi"), url);
+ }, html);
+}
+
+function replaceContactVariables(html: string, contact: Contact) {
+ return html.replace(
+ CONTACT_VARIABLE_REGEX,
+ (_, key: string, fallback?: string) => {
+ const valueMap: Record = {
+ email: contact.email,
+ firstname: contact.firstName,
+ lastname: contact.lastName,
+ };
+
+ const normalizedKey = key.toLowerCase();
+ const contactValue = valueMap[normalizedKey];
+
+ if (contactValue && contactValue.length > 0) {
+ return contactValue;
+ }
+
+ return fallback ?? "";
+ }
+ );
+}
+
+function sanitizeAddressList(addresses?: string | string[]) {
+ if (!addresses) {
+ return [] as string[];
+ }
+
+ const list = Array.isArray(addresses) ? addresses : [addresses];
+
+ return list
+ .map((address) => address.trim())
+ .filter((address) => address.length > 0);
+}
+
+async function prepareCampaignHtml(
+ campaign: Campaign
+): Promise<{ campaign: Campaign; html: string }> {
+ if (campaign.content) {
+ try {
+ const jsonContent = JSON.parse(campaign.content);
+ const renderer = new EmailRenderer(jsonContent);
+ const html = await renderer.render();
+
+ if (campaign.html !== html) {
+ campaign = await db.campaign.update({
+ where: { id: campaign.id },
+ data: { html },
+ });
+ }
+
+ return { campaign, html };
+ } catch (error) {
+ logger.error({ err: error }, "Failed to parse campaign content");
+ throw new Error("Failed to parse campaign content");
+ }
}
- if (!campaign.content) {
- throw new Error("No content added for campaign");
+ if (campaign.html) {
+ return { campaign, html: campaign.html };
}
- let jsonContent: Record;
+ throw new Error("No content added for campaign");
+}
- try {
- jsonContent = JSON.parse(campaign.content);
- const renderer = new EmailRenderer(jsonContent);
- const html = await renderer.render();
- campaign = await db.campaign.update({
- where: { id },
- data: { html },
+async function renderCampaignHtmlForContact({
+ campaign,
+ contact,
+ unsubscribeUrl,
+}: {
+ campaign: Campaign;
+ contact: Contact;
+ unsubscribeUrl: string;
+}) {
+ if (campaign.content) {
+ try {
+ const jsonContent = JSON.parse(campaign.content);
+ const renderer = new EmailRenderer(jsonContent);
+ const linkValues: Record = {};
+
+ for (const token of CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS) {
+ linkValues[token] = unsubscribeUrl;
+ }
+
+ return renderer.render({
+ shouldReplaceVariableValues: true,
+ variableValues: {
+ email: contact.email,
+ firstName: contact.firstName,
+ lastName: contact.lastName,
+ },
+ linkValues,
+ });
+ } catch (error) {
+ logger.error({ err: error }, "Failed to parse campaign content");
+ throw new Error("Failed to parse campaign content");
+ }
+ }
+
+ if (!campaign.html) {
+ throw new Error("No HTML content for campaign");
+ }
+
+ let html = replaceUnsubscribePlaceholders(campaign.html, unsubscribeUrl);
+ html = replaceContactVariables(html, contact);
+
+ return html;
+}
+
+export async function createCampaignFromApi({
+ teamId,
+ apiKeyId,
+ name,
+ from,
+ subject,
+ previewText,
+ content,
+ html,
+ contactBookId,
+ replyTo,
+ cc,
+ bcc,
+ batchSize,
+}: {
+ teamId: number;
+ apiKeyId?: number;
+ name: string;
+ from: string;
+ subject: string;
+ previewText?: string;
+ content?: string;
+ html?: string;
+ contactBookId: string;
+ replyTo?: string | string[];
+ cc?: string | string[];
+ bcc?: string | string[];
+ batchSize?: number;
+}) {
+ if (!content && !html) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Either content or html must be provided",
});
- } catch (error) {
- logger.error({ err: error }, "Failed to parse campaign content");
- throw new Error("Failed to parse campaign content");
}
+ if (content) {
+ try {
+ JSON.parse(content);
+ } catch (error) {
+ logger.error({ err: error }, "Invalid campaign content JSON from API");
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Invalid content JSON",
+ });
+ }
+ }
+
+ const contactBook = await db.contactBook.findUnique({
+ where: { id: contactBookId, teamId },
+ select: { id: true },
+ });
+
+ if (!contactBook) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Contact book not found",
+ });
+ }
+
+ let domain;
+
+ if (apiKeyId) {
+ const apiKey = await db.apiKey.findUnique({
+ where: { id: apiKeyId },
+ include: { domain: true },
+ });
+
+ if (!apiKey || apiKey.teamId !== teamId) {
+ throw new UnsendApiError({
+ code: "FORBIDDEN",
+ message: "Invalid API key",
+ });
+ }
+
+ domain = await validateApiKeyDomainAccess(from, teamId, apiKey);
+ } else {
+ domain = await validateDomainFromEmail(from, teamId);
+ }
+
+ const sanitizedHtml = html?.trim();
+ const sanitizedContent = content ?? null;
+
+ const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
+ sanitizedContent,
+ sanitizedHtml
+ );
+
+ if (!unsubPlaceholderFound) {
+ throw new UnsendApiError({
+ code: "BAD_REQUEST",
+ message: "Campaign must include an unsubscribe link before sending",
+ });
+ }
+
+ const campaign = await db.campaign.create({
+ data: {
+ name,
+ from,
+ subject,
+ isApi: true,
+ ...(previewText !== undefined ? { previewText } : {}),
+ content: sanitizedContent,
+ ...(sanitizedHtml && sanitizedHtml.length > 0
+ ? { html: sanitizedHtml }
+ : {}),
+ contactBookId,
+ replyTo: sanitizeAddressList(replyTo),
+ cc: sanitizeAddressList(cc),
+ bcc: sanitizeAddressList(bcc),
+ teamId,
+ domainId: domain.id,
+ ...(typeof batchSize === "number" ? { batchSize } : {}),
+ },
+ });
+
+ return campaign;
+}
+
+export async function getCampaignForTeam({
+ campaignId,
+ teamId,
+}: {
+ campaignId: string;
+ teamId: number;
+}) {
+ const campaign = await db.campaign.findFirst({
+ where: { id: campaignId, teamId },
+ select: {
+ id: true,
+ name: true,
+ from: true,
+ subject: true,
+ previewText: true,
+ contactBookId: true,
+ html: true,
+ content: true,
+ status: true,
+ scheduledAt: true,
+ batchSize: true,
+ batchWindowMinutes: true,
+ total: true,
+ sent: true,
+ delivered: true,
+ opened: true,
+ clicked: true,
+ unsubscribed: true,
+ bounced: true,
+ hardBounced: true,
+ complained: true,
+ replyTo: true,
+ cc: true,
+ bcc: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ });
+
+ if (!campaign) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Campaign not found",
+ });
+ }
+
+ return campaign;
+}
+
+export async function sendCampaign(id: string) {
+ let campaign = await db.campaign.findUnique({
+ where: { id },
+ });
+
+ if (!campaign) {
+ throw new Error("Campaign not found");
+ }
+
+ const prepared = await prepareCampaignHtml(campaign);
+ campaign = prepared.campaign;
+ const html = prepared.html;
+
if (!campaign.contactBookId) {
throw new Error("No contact book found for campaign");
}
- if (!campaign.html) {
+ if (!html) {
throw new Error("No HTML content for campaign");
}
- const unsubPlaceholderFound = CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.some(
- (placeholder) =>
- campaign.content?.includes(placeholder) ||
- campaign.html?.includes(placeholder)
+ const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
+ campaign.content,
+ html
);
if (!unsubPlaceholderFound) {
@@ -115,26 +402,15 @@ export async function scheduleCampaign({
});
}
- if (!campaign.content) {
- throw new UnsendApiError({
- code: "BAD_REQUEST",
- message: "No content added for campaign",
- });
- }
-
- // Parse & render HTML (idempotent) similar to sendCampaign
+ let html: string;
try {
- const jsonContent = JSON.parse(campaign.content);
- const renderer = new EmailRenderer(jsonContent);
- const html = await renderer.render();
- campaign = await db.campaign.update({
- where: { id: campaign.id },
- data: { html },
- });
+ const prepared = await prepareCampaignHtml(campaign);
+ campaign = prepared.campaign;
+ html = prepared.html;
} catch (err) {
throw new UnsendApiError({
code: "BAD_REQUEST",
- message: "Invalid content",
+ message: err instanceof Error ? err.message : "Invalid campaign content",
});
}
@@ -145,17 +421,16 @@ export async function scheduleCampaign({
});
}
- if (!campaign.html) {
+ if (!html) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "No HTML content for campaign",
});
}
- const unsubPlaceholderFound = CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.some(
- (placeholder) =>
- campaign.content?.includes(placeholder) ||
- campaign.html?.includes(placeholder)
+ const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
+ campaign.content,
+ html
);
if (!unsubPlaceholderFound) {
throw new UnsendApiError({
@@ -429,8 +704,6 @@ type CampaignEmailJob = {
async function processContactEmail(jobData: CampaignEmailJob) {
const { contact, campaign, emailConfig } = jobData;
- const jsonContent = JSON.parse(campaign.content || "{}");
- const renderer = new EmailRenderer(jsonContent);
const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
const oneClickUnsubUrl = createOneClickUnsubUrl(
@@ -467,17 +740,10 @@ async function processContactEmail(jobData: CampaignEmailJob) {
// Check if the contact's email (TO recipient) is suppressed
const isContactSuppressed = filteredToEmails.length === 0;
- const html = await renderer.render({
- shouldReplaceVariableValues: true,
- variableValues: {
- email: contact.email,
- firstName: contact.firstName,
- lastName: contact.lastName,
- },
- linkValues: {
- "{{unsend_unsubscribe_url}}": unsubscribeUrl,
- "{{usesend_unsubscribe_url}}": unsubscribeUrl,
- },
+ const html = await renderCampaignHtmlForContact({
+ campaign,
+ contact,
+ unsubscribeUrl,
});
if (isContactSuppressed) {
diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md
index fff5edc5..647fc871 100644
--- a/packages/python-sdk/README.md
+++ b/packages/python-sdk/README.md
@@ -37,6 +37,29 @@ resp, _ = client.emails.send(payload={
"html": "Hi!",
})
+# 3) Campaigns
+campaign_payload: types.CampaignCreate = {
+ "name": "Welcome Series",
+ "subject": "Welcome to our service!",
+ "html": "Thanks for joining us!
",
+ "from": "welcome@example.com",
+ "contactBookId": "cb_1234567890",
+}
+campaign_resp, _ = client.campaigns.create(payload=campaign_payload)
+
+# Schedule a campaign
+schedule_payload: types.CampaignSchedule = {
+ "scheduledAt": "2024-12-01T10:00:00Z",
+}
+schedule_resp, _ = client.campaigns.schedule(
+ campaign_id=campaign_resp["id"],
+ payload=schedule_payload
+)
+
+# Pause/resume campaigns
+client.campaigns.pause(campaign_id="campaign_123")
+client.campaigns.resume(campaign_id="campaign_123")
+
# Toggle behavior if desired:
# - raise_on_error=False: return (None, error_dict) instead of raising
# No model parsing occurs; methods return plain dicts following the typed shapes.
@@ -55,7 +78,14 @@ This package is managed with Poetry. Models are maintained in-repo under
It is published as `usesend` on PyPI.
+## Available Resources
+
+- **Emails**: `client.emails.send()`, `client.emails.get()`
+- **Contacts**: `client.contacts.create()`, `client.contacts.get()`, `client.contacts.list()`
+- **Domains**: `client.domains.create()`, `client.domains.get()`, `client.domains.verify()`
+- **Campaigns**: `client.campaigns.create()`, `client.campaigns.get()`, `client.campaigns.schedule()`, `client.campaigns.pause()`, `client.campaigns.resume()`
+
Notes
-- Human-friendly models are available under `usesend.types` (e.g., `EmailCreate`, `Contact`, `APIError`).
+- Human-friendly models are available under `usesend.types` (e.g., `EmailCreate`, `CampaignCreate`, `Contact`, `APIError`).
- Endpoint methods accept TypedDict payloads or plain dicts via the `payload=` keyword.
diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml
index d9c72cd3..f9ee12c2 100644
--- a/packages/python-sdk/pyproject.toml
+++ b/packages/python-sdk/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "usesend"
-version = "0.2.6"
+version = "0.2.7"
description = "Python SDK for the UseSend API"
authors = ["UseSend"]
license = "MIT"
diff --git a/packages/python-sdk/usesend/__init__.py b/packages/python-sdk/usesend/__init__.py
index 5efbdbd8..91949757 100644
--- a/packages/python-sdk/usesend/__init__.py
+++ b/packages/python-sdk/usesend/__init__.py
@@ -2,6 +2,7 @@
from .usesend import UseSend, UseSendHTTPError
from .domains import Domains # type: ignore
+from .campaigns import Campaigns # type: ignore
from . import types
-__all__ = ["UseSend", "UseSendHTTPError", "types", "Domains"]
+__all__ = ["UseSend", "UseSendHTTPError", "types", "Domains", "Campaigns"]
diff --git a/packages/python-sdk/usesend/campaigns.py b/packages/python-sdk/usesend/campaigns.py
new file mode 100644
index 00000000..eb1ec7b7
--- /dev/null
+++ b/packages/python-sdk/usesend/campaigns.py
@@ -0,0 +1,68 @@
+"""Campaign resource client using TypedDict shapes (no Pydantic)."""
+from __future__ import annotations
+
+from typing import Any, Dict, Optional, Tuple
+
+from .types import (
+ APIError,
+ Campaign,
+ CampaignCreate,
+ CampaignCreateResponse,
+ CampaignSchedule,
+ CampaignScheduleResponse,
+ CampaignActionResponse,
+)
+
+
+class Campaigns:
+ """Client for `/campaigns` endpoints."""
+
+ def __init__(self, usesend: "UseSend") -> None:
+ self.usesend = usesend
+
+ def create(
+ self, payload: CampaignCreate
+ ) -> Tuple[Optional[CampaignCreateResponse], Optional[APIError]]:
+ data, err = self.usesend.post(
+ "/campaigns",
+ payload,
+ )
+ return (data, err) # type: ignore[return-value]
+
+ def get(
+ self, campaign_id: str
+ ) -> Tuple[Optional[Campaign], Optional[APIError]]:
+ data, err = self.usesend.get(
+ f"/campaigns/{campaign_id}"
+ )
+ return (data, err) # type: ignore[return-value]
+
+ def schedule(
+ self, campaign_id: str, payload: CampaignSchedule
+ ) -> Tuple[Optional[CampaignScheduleResponse], Optional[APIError]]:
+ data, err = self.usesend.post(
+ f"/campaigns/{campaign_id}/schedule",
+ payload,
+ )
+ return (data, err) # type: ignore[return-value]
+
+ def pause(
+ self, campaign_id: str
+ ) -> Tuple[Optional[CampaignActionResponse], Optional[APIError]]:
+ data, err = self.usesend.post(
+ f"/campaigns/{campaign_id}/pause",
+ {},
+ )
+ return (data, err) # type: ignore[return-value]
+
+ def resume(
+ self, campaign_id: str
+ ) -> Tuple[Optional[CampaignActionResponse], Optional[APIError]]:
+ data, err = self.usesend.post(
+ f"/campaigns/{campaign_id}/resume",
+ {},
+ )
+ return (data, err) # type: ignore[return-value]
+
+
+from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position
\ No newline at end of file
diff --git a/packages/python-sdk/usesend/types.py b/packages/python-sdk/usesend/types.py
index d9669060..6ec188de 100644
--- a/packages/python-sdk/usesend/types.py
+++ b/packages/python-sdk/usesend/types.py
@@ -15,14 +15,14 @@
# ---------------------------------------------------------------------------
DomainStatus = Literal[
- 'NOT_STARTED',
- 'PENDING',
- 'SUCCESS',
- 'FAILED',
- 'TEMPORARY_FAILURE',
+ "NOT_STARTED",
+ "PENDING",
+ "SUCCESS",
+ "FAILED",
+ "TEMPORARY_FAILURE",
]
-DNSRecordType = Literal['MX', 'TXT']
+DNSRecordType = Literal["MX", "TXT"]
class DNSRecord(TypedDict, total=False):
@@ -99,24 +99,25 @@ class DomainDeleteResponse(TypedDict):
success: bool
message: str
+
# ---------------------------------------------------------------------------
# Emails
# ---------------------------------------------------------------------------
EmailEventStatus = Literal[
- 'SCHEDULED',
- 'QUEUED',
- 'SENT',
- 'DELIVERY_DELAYED',
- 'BOUNCED',
- 'REJECTED',
- 'RENDERING_FAILURE',
- 'DELIVERED',
- 'OPENED',
- 'CLICKED',
- 'COMPLAINED',
- 'FAILED',
- 'CANCELLED',
+ "SCHEDULED",
+ "QUEUED",
+ "SENT",
+ "DELIVERY_DELAYED",
+ "BOUNCED",
+ "REJECTED",
+ "RENDERING_FAILURE",
+ "DELIVERED",
+ "OPENED",
+ "CLICKED",
+ "COMPLAINED",
+ "FAILED",
+ "CANCELLED",
]
@@ -128,22 +129,22 @@ class EmailEvent(TypedDict, total=False):
Email = TypedDict(
- 'Email',
+ "Email",
{
- 'id': str,
- 'teamId': float,
- 'to': Union[str, List[str]],
- 'replyTo': NotRequired[Union[str, List[str]]],
- 'cc': NotRequired[Union[str, List[str]]],
- 'bcc': NotRequired[Union[str, List[str]]],
- 'from': str,
- 'subject': str,
- 'html': str,
- 'text': str,
- 'createdAt': str,
- 'updatedAt': str,
- 'emailEvents': List[EmailEvent],
- }
+ "id": str,
+ "teamId": float,
+ "to": Union[str, List[str]],
+ "replyTo": NotRequired[Union[str, List[str]]],
+ "cc": NotRequired[Union[str, List[str]]],
+ "bcc": NotRequired[Union[str, List[str]]],
+ "from": str,
+ "subject": str,
+ "html": str,
+ "text": str,
+ "createdAt": str,
+ "updatedAt": str,
+ "emailEvents": List[EmailEvent],
+ },
)
@@ -157,40 +158,40 @@ class EmailUpdateResponse(TypedDict, total=False):
EmailLatestStatus = Literal[
- 'SCHEDULED',
- 'QUEUED',
- 'SENT',
- 'DELIVERY_DELAYED',
- 'BOUNCED',
- 'REJECTED',
- 'RENDERING_FAILURE',
- 'DELIVERED',
- 'OPENED',
- 'CLICKED',
- 'COMPLAINED',
- 'FAILED',
- 'CANCELLED',
+ "SCHEDULED",
+ "QUEUED",
+ "SENT",
+ "DELIVERY_DELAYED",
+ "BOUNCED",
+ "REJECTED",
+ "RENDERING_FAILURE",
+ "DELIVERED",
+ "OPENED",
+ "CLICKED",
+ "COMPLAINED",
+ "FAILED",
+ "CANCELLED",
]
EmailListItem = TypedDict(
- 'EmailListItem',
+ "EmailListItem",
{
- 'id': str,
- 'to': Union[str, List[str]],
- 'replyTo': NotRequired[Union[str, List[str]]],
- 'cc': NotRequired[Union[str, List[str]]],
- 'bcc': NotRequired[Union[str, List[str]]],
- 'from': str,
- 'subject': str,
- 'html': str,
- 'text': str,
- 'createdAt': str,
- 'updatedAt': str,
- 'latestStatus': EmailLatestStatus,
- 'scheduledAt': str,
- 'domainId': float,
- }
+ "id": str,
+ "to": Union[str, List[str]],
+ "replyTo": NotRequired[Union[str, List[str]]],
+ "cc": NotRequired[Union[str, List[str]]],
+ "bcc": NotRequired[Union[str, List[str]]],
+ "from": str,
+ "subject": str,
+ "html": str,
+ "text": str,
+ "createdAt": str,
+ "updatedAt": str,
+ "latestStatus": EmailLatestStatus,
+ "scheduledAt": str,
+ "domainId": float,
+ },
)
@@ -205,23 +206,23 @@ class Attachment(TypedDict):
EmailCreate = TypedDict(
- 'EmailCreate',
+ "EmailCreate",
{
- 'to': Required[Union[str, List[str]]],
- 'from': Required[str],
- 'subject': NotRequired[str],
- 'templateId': NotRequired[str],
- 'variables': NotRequired[Dict[str, str]],
- 'replyTo': NotRequired[Union[str, List[str]]],
- 'cc': NotRequired[Union[str, List[str]]],
- 'bcc': NotRequired[Union[str, List[str]]],
- 'text': NotRequired[str],
- 'html': NotRequired[str],
- 'attachments': NotRequired[List[Attachment]],
- 'scheduledAt': NotRequired[Union[datetime, str]],
- 'inReplyToId': NotRequired[str],
- 'headers': NotRequired[Dict[str, str]],
- }
+ "to": Required[Union[str, List[str]]],
+ "from": Required[str],
+ "subject": NotRequired[str],
+ "templateId": NotRequired[str],
+ "variables": NotRequired[Dict[str, str]],
+ "replyTo": NotRequired[Union[str, List[str]]],
+ "cc": NotRequired[Union[str, List[str]]],
+ "bcc": NotRequired[Union[str, List[str]]],
+ "text": NotRequired[str],
+ "html": NotRequired[str],
+ "attachments": NotRequired[List[Attachment]],
+ "scheduledAt": NotRequired[Union[datetime, str]],
+ "inReplyToId": NotRequired[str],
+ "headers": NotRequired[Dict[str, str]],
+ },
)
@@ -230,23 +231,23 @@ class EmailCreateResponse(TypedDict, total=False):
EmailBatchItem = TypedDict(
- 'EmailBatchItem',
+ "EmailBatchItem",
{
- 'to': Required[Union[str, List[str]]],
- 'from': Required[str],
- 'subject': NotRequired[str],
- 'templateId': NotRequired[str],
- 'variables': NotRequired[Dict[str, str]],
- 'replyTo': NotRequired[Union[str, List[str]]],
- 'cc': NotRequired[Union[str, List[str]]],
- 'bcc': NotRequired[Union[str, List[str]]],
- 'text': NotRequired[str],
- 'html': NotRequired[str],
- 'attachments': NotRequired[List[Attachment]],
- 'scheduledAt': NotRequired[Union[datetime, str]],
- 'inReplyToId': NotRequired[str],
- 'headers': NotRequired[Dict[str, str]],
- }
+ "to": Required[Union[str, List[str]]],
+ "from": Required[str],
+ "subject": NotRequired[str],
+ "templateId": NotRequired[str],
+ "variables": NotRequired[Dict[str, str]],
+ "replyTo": NotRequired[Union[str, List[str]]],
+ "cc": NotRequired[Union[str, List[str]]],
+ "bcc": NotRequired[Union[str, List[str]]],
+ "text": NotRequired[str],
+ "html": NotRequired[str],
+ "attachments": NotRequired[List[Attachment]],
+ "scheduledAt": NotRequired[Union[datetime, str]],
+ "inReplyToId": NotRequired[str],
+ "headers": NotRequired[Dict[str, str]],
+ },
)
@@ -269,6 +270,7 @@ class EmailCancelResponse(TypedDict, total=False):
# Contacts
# ---------------------------------------------------------------------------
+
class ContactCreate(TypedDict, total=False):
email: str
firstName: Optional[str]
@@ -335,11 +337,115 @@ class ContactDeleteResponse(TypedDict):
success: bool
+# ---------------------------------------------------------------------------
+# Campaigns
+# ---------------------------------------------------------------------------
+
+Campaign = TypedDict(
+ "Campaign",
+ {
+ "id": str,
+ "name": str,
+ "from": str,
+ "subject": str,
+ "previewText": Optional[str],
+ "contactBookId": Optional[str],
+ "html": Optional[str],
+ "content": Optional[str],
+ "status": str,
+ "scheduledAt": Optional[str],
+ "batchSize": int,
+ "batchWindowMinutes": int,
+ "total": int,
+ "sent": int,
+ "delivered": int,
+ "opened": int,
+ "clicked": int,
+ "unsubscribed": int,
+ "bounced": int,
+ "hardBounced": int,
+ "complained": int,
+ "replyTo": List[str],
+ "cc": List[str],
+ "bcc": List[str],
+ "createdAt": str,
+ "updatedAt": str,
+ },
+)
+
+
+CampaignCreate = TypedDict(
+ "CampaignCreate",
+ {
+ "name": Required[str],
+ "from": Required[str],
+ "subject": Required[str],
+ "previewText": NotRequired[str],
+ "contactBookId": Required[str],
+ "content": NotRequired[str],
+ "html": NotRequired[str],
+ "replyTo": NotRequired[Union[str, List[str]]],
+ "cc": NotRequired[Union[str, List[str]]],
+ "bcc": NotRequired[Union[str, List[str]]],
+ "sendNow": NotRequired[bool],
+ "scheduledAt": NotRequired[str],
+ "batchSize": NotRequired[int],
+ },
+)
+
+
+CampaignCreateResponse = TypedDict(
+ "CampaignCreateResponse",
+ {
+ "id": str,
+ "name": str,
+ "from": str,
+ "subject": str,
+ "previewText": Optional[str],
+ "contactBookId": Optional[str],
+ "html": Optional[str],
+ "content": Optional[str],
+ "status": str,
+ "scheduledAt": Optional[str],
+ "batchSize": int,
+ "batchWindowMinutes": int,
+ "total": int,
+ "sent": int,
+ "delivered": int,
+ "opened": int,
+ "clicked": int,
+ "unsubscribed": int,
+ "bounced": int,
+ "hardBounced": int,
+ "complained": int,
+ "replyTo": List[str],
+ "cc": List[str],
+ "bcc": List[str],
+ "createdAt": str,
+ "updatedAt": str,
+ },
+)
+
+
+class CampaignSchedule(TypedDict, total=False):
+ scheduledAt: Optional[str]
+ batchSize: Optional[int]
+ sendNow: Optional[bool]
+
+
+class CampaignScheduleResponse(TypedDict, total=False):
+ success: bool
+
+
+class CampaignActionResponse(TypedDict, total=False):
+ success: bool
+
+
# ---------------------------------------------------------------------------
# Common
# ---------------------------------------------------------------------------
+
class APIError(TypedDict):
code: str
message: str
-
diff --git a/packages/python-sdk/usesend/usesend.py b/packages/python-sdk/usesend/usesend.py
index d699e7b6..ba31a016 100644
--- a/packages/python-sdk/usesend/usesend.py
+++ b/packages/python-sdk/usesend/usesend.py
@@ -72,6 +72,7 @@ def __init__(
self.emails = Emails(self)
self.contacts = Contacts(self)
self.domains = Domains(self)
+ self.campaigns = Campaigns(self)
# ------------------------------------------------------------------
# Internal request helper
@@ -125,3 +126,4 @@ def delete(
from .emails import Emails # noqa: E402 pylint: disable=wrong-import-position
from .contacts import Contacts # noqa: E402 pylint: disable=wrong-import-position
from .domains import Domains # type: ignore # noqa: E402
+from .campaigns import Campaigns # type: ignore # noqa: E402
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index dcaf696a..8c5c73fb 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -49,3 +49,38 @@ usesend.emails.send({
text: "useSend is the best open source product to send emails",
});
```
+
+## Campaigns
+
+Create and manage email campaigns:
+
+```javascript
+import { UseSend } from "usesend";
+
+const usesend = new UseSend("us_12345");
+
+// Create a campaign
+const campaign = await usesend.campaigns.create({
+ name: "Welcome Series",
+ from: "hello@company.com",
+ subject: "Welcome to our platform!",
+ contactBookId: "cb_12345",
+ html: "Welcome!
Thanks for joining us.
",
+ sendNow: false,
+});
+
+// Schedule a campaign
+await usesend.campaigns.schedule(campaign.data.id, {
+ scheduledAt: "2024-12-01T09:00:00Z",
+ batchSize: 1000,
+});
+
+// Get campaign details
+const details = await usesend.campaigns.get(campaign.data.id);
+
+// Pause a campaign
+await usesend.campaigns.pause(campaign.data.id);
+
+// Resume a campaign
+await usesend.campaigns.resume(campaign.data.id);
+```
diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts
index 5892d5e4..2b8da96d 100644
--- a/packages/sdk/index.ts
+++ b/packages/sdk/index.ts
@@ -1,2 +1,3 @@
export { UseSend } from "./src/usesend";
export { UseSend as Unsend } from "./src/usesend"; // deprecated alias
+export { Campaigns } from "./src/campaign";
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index bd87bd64..e8f21cff 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "usesend-js",
- "version": "1.5.5",
+ "version": "1.5.6",
"description": "",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
diff --git a/packages/sdk/src/campaign.ts b/packages/sdk/src/campaign.ts
new file mode 100644
index 00000000..39dbca46
--- /dev/null
+++ b/packages/sdk/src/campaign.ts
@@ -0,0 +1,94 @@
+import { UseSend } from "./usesend";
+import { paths } from "../types/schema";
+import { ErrorResponse } from "../types";
+
+type CreateCampaignPayload =
+ paths["/v1/campaigns"]["post"]["requestBody"]["content"]["application/json"];
+
+type CreateCampaignResponse = {
+ data: CreateCampaignResponseSuccess | null;
+ error: ErrorResponse | null;
+};
+
+type CreateCampaignResponseSuccess =
+ paths["/v1/campaigns"]["post"]["responses"]["200"]["content"]["application/json"];
+
+type GetCampaignResponseSuccess =
+ paths["/v1/campaigns/{campaignId}"]["get"]["responses"]["200"]["content"]["application/json"];
+
+type GetCampaignResponse = {
+ data: GetCampaignResponseSuccess | null;
+ error: ErrorResponse | null;
+};
+
+type ScheduleCampaignPayload =
+ paths["/v1/campaigns/{campaignId}/schedule"]["post"]["requestBody"]["content"]["application/json"];
+
+type ScheduleCampaignResponseSuccess =
+ paths["/v1/campaigns/{campaignId}/schedule"]["post"]["responses"]["200"]["content"]["application/json"];
+
+type ScheduleCampaignResponse = {
+ data: ScheduleCampaignResponseSuccess | null;
+ error: ErrorResponse | null;
+};
+
+type CampaignActionResponseSuccess = { success: boolean };
+
+type CampaignActionResponse = {
+ data: CampaignActionResponseSuccess | null;
+ error: ErrorResponse | null;
+};
+
+export class Campaigns {
+ constructor(private readonly usesend: UseSend) {
+ this.usesend = usesend;
+ }
+
+ async create(
+ payload: CreateCampaignPayload,
+ ): Promise {
+ const data = await this.usesend.post(
+ `/campaigns`,
+ payload,
+ );
+
+ return data;
+ }
+
+ async get(campaignId: string): Promise {
+ const data = await this.usesend.get(
+ `/campaigns/${campaignId}`,
+ );
+ return data;
+ }
+
+ async schedule(
+ campaignId: string,
+ payload: ScheduleCampaignPayload,
+ ): Promise {
+ const data = await this.usesend.post(
+ `/campaigns/${campaignId}/schedule`,
+ payload,
+ );
+
+ return data;
+ }
+
+ async pause(campaignId: string): Promise {
+ const data = await this.usesend.post(
+ `/campaigns/${campaignId}/pause`,
+ {},
+ );
+
+ return data;
+ }
+
+ async resume(campaignId: string): Promise {
+ const data = await this.usesend.post(
+ `/campaigns/${campaignId}/resume`,
+ {},
+ );
+
+ return data;
+ }
+}
diff --git a/packages/sdk/src/usesend.ts b/packages/sdk/src/usesend.ts
index 2f3acf6c..3f4b016f 100644
--- a/packages/sdk/src/usesend.ts
+++ b/packages/sdk/src/usesend.ts
@@ -2,6 +2,7 @@ import { ErrorResponse } from "../types";
import { Contacts } from "./contact";
import { Emails } from "./email";
import { Domains } from "./domain";
+import { Campaigns } from "./campaign";
const defaultBaseUrl = "https://app.usesend.com";
// eslint-disable-next-line turbo/no-undeclared-env-vars
@@ -18,6 +19,7 @@ export class UseSend {
readonly emails = new Emails(this);
readonly domains = new Domains(this);
readonly contacts = new Contacts(this);
+ readonly campaigns = new Campaigns(this);
url = baseUrl;
constructor(
diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts
index 7472a62f..6ef77a9c 100644
--- a/packages/sdk/types/schema.d.ts
+++ b/packages/sdk/types/schema.d.ts
@@ -959,6 +959,282 @@ export interface paths {
};
trace?: never;
};
+ "/v1/campaigns": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ name: string;
+ from: string;
+ subject: string;
+ previewText?: string;
+ contactBookId: string;
+ content?: string;
+ html?: string;
+ replyTo?: string | string[];
+ cc?: string | string[];
+ bcc?: string | string[];
+ sendNow?: boolean;
+ /** @description Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am', 'next monday 10:30') */
+ scheduledAt?: string;
+ batchSize?: number;
+ };
+ };
+ };
+ responses: {
+ /** @description Create a campaign */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ id: string;
+ name: string;
+ from: string;
+ subject: string;
+ previewText: string | null;
+ contactBookId: string | null;
+ html: string | null;
+ content: string | null;
+ status: string;
+ /** Format: date-time */
+ scheduledAt: string | null;
+ batchSize: number;
+ batchWindowMinutes: number;
+ total: number;
+ sent: number;
+ delivered: number;
+ opened: number;
+ clicked: number;
+ unsubscribed: number;
+ bounced: number;
+ hardBounced: number;
+ complained: number;
+ replyTo: string[];
+ cc: string[];
+ bcc: string[];
+ /** Format: date-time */
+ createdAt: string;
+ /** Format: date-time */
+ updatedAt: string;
+ };
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/campaigns/{campaignId}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ campaignId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Get campaign details */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ id: string;
+ name: string;
+ from: string;
+ subject: string;
+ previewText: string | null;
+ contactBookId: string | null;
+ html: string | null;
+ content: string | null;
+ status: string;
+ /** Format: date-time */
+ scheduledAt: string | null;
+ batchSize: number;
+ batchWindowMinutes: number;
+ total: number;
+ sent: number;
+ delivered: number;
+ opened: number;
+ clicked: number;
+ unsubscribed: number;
+ bounced: number;
+ hardBounced: number;
+ complained: number;
+ replyTo: string[];
+ cc: string[];
+ bcc: string[];
+ /** Format: date-time */
+ createdAt: string;
+ /** Format: date-time */
+ updatedAt: string;
+ };
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/campaigns/{campaignId}/schedule": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ campaignId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am', 'next monday 10:30') */
+ scheduledAt?: string;
+ batchSize?: number;
+ };
+ };
+ };
+ responses: {
+ /** @description Schedule a campaign */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ success: boolean;
+ };
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/campaigns/{campaignId}/pause": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ campaignId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Pause a campaign */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ success: boolean;
+ };
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/campaigns/{campaignId}/resume": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ campaignId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Resume a campaign */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ success: boolean;
+ };
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record;
export interface components {