From cf52cd8988360ff369f9a54ae3359089b00b5d74 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Mon, 13 Oct 2025 20:36:28 +1100 Subject: [PATCH 1/8] add campaign api --- apps/web/src/server/api/routers/campaign.ts | 25 +- .../api/campaigns/create-campaign.ts | 79 ++++ .../public-api/api/campaigns/get-campaign.ts | 49 +++ .../api/campaigns/pause-campaign.ts | 59 +++ .../api/campaigns/resume-campaign.ts | 59 +++ .../api/campaigns/schedule-campaign.ts | 73 ++++ apps/web/src/server/public-api/index.ts | 12 + .../public-api/schemas/campaign-schema.ts | 66 ++++ .../src/server/service/campaign-service.ts | 369 +++++++++++++++--- 9 files changed, 725 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/server/public-api/api/campaigns/create-campaign.ts create mode 100644 apps/web/src/server/public-api/api/campaigns/get-campaign.ts create mode 100644 apps/web/src/server/public-api/api/campaigns/pause-campaign.ts create mode 100644 apps/web/src/server/public-api/api/campaigns/resume-campaign.ts create mode 100644 apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts create mode 100644 apps/web/src/server/public-api/schemas/campaign-schema.ts diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts index 6f1cac24..5c433f88 100644 --- a/apps/web/src/server/api/routers/campaign.ts +++ b/apps/web/src/server/api/routers/campaign.ts @@ -119,12 +119,13 @@ 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 { campaignId, html: htmlInput, ...data } = input; if (data.contactBookId) { const contactBook = await db.contactBook.findUnique({ where: { id: data.contactBookId }, @@ -143,22 +144,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; }), @@ -240,6 +248,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..5b1a2d2d --- /dev/null +++ b/apps/web/src/server/public-api/api/campaigns/create-campaign.ts @@ -0,0 +1,79 @@ +import { createRoute, z } from "@hono/zod-openapi"; +import { PublicAPIApp } from "~/server/public-api/hono"; +import { + campaignCreateSchema, + CampaignCreateInput, + campaignResponseSchema, +} 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() : 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..38a2e034 --- /dev/null +++ b/apps/web/src/server/public-api/api/campaigns/pause-campaign.ts @@ -0,0 +1,59 @@ +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, + }); + + await getCampaignForTeam({ + 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..5b7d0ace --- /dev/null +++ b/apps/web/src/server/public-api/api/campaigns/resume-campaign.ts @@ -0,0 +1,59 @@ +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 } 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..3a32709d --- /dev/null +++ b/apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts @@ -0,0 +1,73 @@ +import { createRoute, z } from "@hono/zod-openapi"; +import { PublicAPIApp } from "~/server/public-api/hono"; +import { + campaignScheduleSchema, + CampaignScheduleInput, + campaignResponseSchema, +} 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: body.scheduledAt, + batchSize: body.batchSize, + }); + + await getCampaignForTeam({ + campaignId, + teamId: team.id, + }); + + 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..1331eada --- /dev/null +++ b/apps/web/src/server/public-api/schemas/campaign-schema.ts @@ -0,0 +1,66 @@ +import { z } from "@hono/zod-openapi"; + +const stringOrStringArray = z.union([ + z.string().min(1), + z.array(z.string().min(1)), +]); + +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().datetime({ offset: true }).optional(), + 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().datetime({ offset: true }).optional(), + 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..31eb40ea 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -19,52 +19,326 @@ 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[]; } - if (!campaign.content) { - throw new Error("No content added for campaign"); + 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"); + } } - let jsonContent: Record; + if (campaign.html) { + return { campaign, html: campaign.html }; + } - 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 }, + throw new Error("No content added for campaign"); +} + +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 campaign = await db.campaign.create({ + data: { + name, + from, + subject, + ...(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 +389,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 +408,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 +691,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 +727,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) { From e8a2985590da4b06d0dbb04992095547f3336dc1 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Mon, 13 Oct 2025 21:37:57 +1100 Subject: [PATCH 2/8] uodate --- apps/docs/api-reference/openapi.json | 525 ++++++++++++++++++ .../public-api/schemas/campaign-schema.ts | 6 +- .../src/server/service/campaign-service.ts | 12 + 3 files changed, 542 insertions(+), 1 deletion(-) diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 308164e1..31e3e012 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1910,6 +1910,531 @@ } } } + }, + "/v1/campaigns": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "from": { + "type": "string", + "minLength": 1 + }, + "subject": { + "type": "string", + "minLength": 1 + }, + "previewText": { + "type": "string" + }, + "contactBookId": { + "type": "string", + "minLength": 1 + }, + "content": { + "type": "string", + "minLength": 1 + }, + "html": { + "type": "string", + "minLength": 1 + }, + "replyTo": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] + }, + "cc": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] + }, + "bcc": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] + }, + "sendNow": { + "type": "boolean" + }, + "scheduledAt": { + "type": "string", + "format": "date-time" + }, + "batchSize": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + } + }, + "required": [ + "name", + "from", + "subject", + "contactBookId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Create a campaign", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "from": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "previewText": { + "type": "string", + "nullable": true + }, + "contactBookId": { + "type": "string", + "nullable": true + }, + "html": { + "type": "string", + "nullable": true + }, + "content": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "scheduledAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "batchSize": { + "type": "integer" + }, + "batchWindowMinutes": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "sent": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "unsubscribed": { + "type": "integer" + }, + "bounced": { + "type": "integer" + }, + "hardBounced": { + "type": "integer" + }, + "complained": { + "type": "integer" + }, + "replyTo": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "from", + "subject", + "previewText", + "contactBookId", + "html", + "content", + "status", + "scheduledAt", + "batchSize", + "batchWindowMinutes", + "total", + "sent", + "delivered", + "opened", + "clicked", + "unsubscribed", + "bounced", + "hardBounced", + "complained", + "replyTo", + "cc", + "bcc", + "createdAt", + "updatedAt" + ] + } + } + } + } + } + } + }, + "/v1/campaigns/{campaignId}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "cmp_123" + }, + "required": true, + "name": "campaignId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Get campaign details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "from": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "previewText": { + "type": "string", + "nullable": true + }, + "contactBookId": { + "type": "string", + "nullable": true + }, + "html": { + "type": "string", + "nullable": true + }, + "content": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "scheduledAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "batchSize": { + "type": "integer" + }, + "batchWindowMinutes": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "sent": { + "type": "integer" + }, + "delivered": { + "type": "integer" + }, + "opened": { + "type": "integer" + }, + "clicked": { + "type": "integer" + }, + "unsubscribed": { + "type": "integer" + }, + "bounced": { + "type": "integer" + }, + "hardBounced": { + "type": "integer" + }, + "complained": { + "type": "integer" + }, + "replyTo": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "from", + "subject", + "previewText", + "contactBookId", + "html", + "content", + "status", + "scheduledAt", + "batchSize", + "batchWindowMinutes", + "total", + "sent", + "delivered", + "opened", + "clicked", + "unsubscribed", + "bounced", + "hardBounced", + "complained", + "replyTo", + "cc", + "bcc", + "createdAt", + "updatedAt" + ] + } + } + } + } + } + } + }, + "/v1/campaigns/{campaignId}/schedule": { + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "cmp_123" + }, + "required": true, + "name": "campaignId", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scheduledAt": { + "type": "string", + "format": "date-time" + }, + "batchSize": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Schedule a campaign", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + } + } + } + }, + "/v1/campaigns/{campaignId}/pause": { + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "cmp_123" + }, + "required": true, + "name": "campaignId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Pause a campaign", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + } + } + } + }, + "/v1/campaigns/{campaignId}/resume": { + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "cmp_123" + }, + "required": true, + "name": "campaignId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Resume a campaign", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + } + } + } + } + } + } } } } \ No newline at end of file diff --git a/apps/web/src/server/public-api/schemas/campaign-schema.ts b/apps/web/src/server/public-api/schemas/campaign-schema.ts index 1331eada..c89349e8 100644 --- a/apps/web/src/server/public-api/schemas/campaign-schema.ts +++ b/apps/web/src/server/public-api/schemas/campaign-schema.ts @@ -18,7 +18,11 @@ export const campaignCreateSchema = z cc: stringOrStringArray.optional(), bcc: stringOrStringArray.optional(), sendNow: z.boolean().optional(), - scheduledAt: z.string().datetime({ offset: true }).optional(), + scheduledAt: z + .string() + .datetime() + .optional() + .describe("Timestamp in ISO 8601 format"), batchSize: z.number().int().min(1).max(100_000).optional(), }) .refine( diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 31eb40ea..28cdf171 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -243,6 +243,18 @@ export async function createCampaignFromApi({ 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 64e4f6a7eca850e6b6e7d6d04bfe6bf830f1dffc Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Mon, 13 Oct 2025 22:58:24 +1100 Subject: [PATCH 3/8] stuff --- .../migration.sql | 2 + apps/web/prisma/schema.prisma | 1 + .../campaigns/[campaignId]/edit/page.tsx | 70 +++++++++++++++---- .../api/campaigns/create-campaign.ts | 5 +- .../api/campaigns/resume-campaign.ts | 5 +- .../api/campaigns/schedule-campaign.ts | 3 +- .../public-api/schemas/campaign-schema.ts | 35 +++++++++- .../src/server/service/campaign-service.ts | 1 + 8 files changed, 101 insertions(+), 21 deletions(-) create mode 100644 apps/web/prisma/migrations/20251013114734_add_api_to_campaign/migration.sql diff --git a/apps/web/prisma/migrations/20251013114734_add_api_to_campaign/migration.sql b/apps/web/prisma/migrations/20251013114734_add_api_to_campaign/migration.sql new file mode 100644 index 00000000..9b04304f --- /dev/null +++ b/apps/web/prisma/migrations/20251013114734_add_api_to_campaign/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Campaign" ADD COLUMN "isApi" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 19cc4636..772d23b1 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -365,6 +365,7 @@ model Campaign { bounced Int @default(0) hardBounced Int @default(0) complained Int @default(0) + isApi Boolean @default(false) status CampaignStatus @default(DRAFT) batchSize Int @default(500) batchWindowMinutes Int @default(0) diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx index 3fb1cba3..9def47c5 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx @@ -97,6 +97,7 @@ function CampaignEditor({ campaign: Campaign & { imageUploadSupported: boolean }; }) { const router = useRouter(); + const isApiCampaign = campaign.isApi; const contactBooksQuery = api.contacts.getContactBooks.useQuery({}); const utils = api.useUtils(); @@ -124,6 +125,9 @@ function CampaignEditor({ const getUploadUrl = api.campaign.generateImagePresignedUrl.useMutation(); function updateEditorContent() { + if (isApiCampaign) { + return; + } updateCampaignMutation.mutate({ campaignId: campaign.id, content: JSON.stringify(json), @@ -175,7 +179,12 @@ function CampaignEditor({ value={name} onChange={(e) => setName(e.target.value)} className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]" + disabled={isApiCampaign} + readOnly={isApiCampaign} onBlur={() => { + if (isApiCampaign) { + return; + } if (name === campaign.name || !name) { return; } @@ -228,6 +237,9 @@ function CampaignEditor({ setSubject(e.target.value); }} onBlur={() => { + if (isApiCampaign) { + return; + } if (subject === campaign.subject || !subject) { return; } @@ -245,6 +257,8 @@ function CampaignEditor({ ); }} className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent" + disabled={isApiCampaign} + readOnly={isApiCampaign} /> @@ -263,6 +277,9 @@ function CampaignEditor({ className="mt-1 py-1 w-full text-sm outline-none border-b border-transparent focus:border-border bg-transparent" placeholder="Friendly name" onBlur={() => { + if (isApiCampaign) { + return; + } if (from === campaign.from || !from) { return; } @@ -279,6 +296,8 @@ function CampaignEditor({ } ); }} + disabled={isApiCampaign} + readOnly={isApiCampaign} />
@@ -294,6 +313,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 +332,8 @@ function CampaignEditor({ } ); }} + disabled={isApiCampaign} + readOnly={isApiCampaign} />
@@ -324,6 +348,9 @@ function CampaignEditor({ setPreviewText(e.target.value); }} onBlur={() => { + if (isApiCampaign) { + return; + } if ( previewText === campaign.previewText || !previewText @@ -344,6 +371,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 +384,11 @@ function CampaignEditor({ ) : (