add campaign api (#274)
This commit is contained in:
@@ -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),
|
||||
@@ -142,8 +146,6 @@ function CampaignEditor({
|
||||
);
|
||||
}
|
||||
|
||||
console.log("file type: ", file.type);
|
||||
|
||||
const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
@@ -175,7 +177,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 +235,9 @@ function CampaignEditor({
|
||||
setSubject(e.target.value);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isApiCampaign) {
|
||||
return;
|
||||
}
|
||||
if (subject === campaign.subject || !subject) {
|
||||
return;
|
||||
}
|
||||
@@ -245,6 +255,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}
|
||||
/>
|
||||
<AccordionTrigger className="py-0"></AccordionTrigger>
|
||||
</div>
|
||||
@@ -263,6 +275,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<hello@example.com>"
|
||||
onBlur={() => {
|
||||
if (isApiCampaign) {
|
||||
return;
|
||||
}
|
||||
if (from === campaign.from || !from) {
|
||||
return;
|
||||
}
|
||||
@@ -279,6 +294,8 @@ function CampaignEditor({
|
||||
}
|
||||
);
|
||||
}}
|
||||
disabled={isApiCampaign}
|
||||
readOnly={isApiCampaign}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
<div className=" flex items-center gap-2">
|
||||
@@ -355,7 +382,11 @@ function CampaignEditor({
|
||||
) : (
|
||||
<Select
|
||||
value={contactBookId ?? ""}
|
||||
disabled={isApiCampaign}
|
||||
onValueChange={(val) => {
|
||||
if (isApiCampaign) {
|
||||
return;
|
||||
}
|
||||
// Update the campaign's contactBookId
|
||||
updateCampaignMutation.mutate(
|
||||
{
|
||||
@@ -395,22 +426,29 @@ function CampaignEditor({
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
|
||||
<div className="w-[600px] mx-auto">
|
||||
<Editor
|
||||
initialContent={json}
|
||||
onUpdate={(content) => {
|
||||
setJson(content.getJSON());
|
||||
setIsSaving(true);
|
||||
deboucedUpdateCampaign();
|
||||
}}
|
||||
variables={["email", "firstName", "lastName"]}
|
||||
uploadImage={
|
||||
campaign.imageUploadSupported ? handleFileChange : undefined
|
||||
}
|
||||
/>
|
||||
{isApiCampaign ? (
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Email created from API. Campaign content can only be updated via
|
||||
API.
|
||||
</p>
|
||||
) : (
|
||||
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
|
||||
<div className="w-[600px] mx-auto">
|
||||
<Editor
|
||||
initialContent={json}
|
||||
onUpdate={(content) => {
|
||||
setJson(content.getJSON());
|
||||
setIsSaving(true);
|
||||
deboucedUpdateCampaign();
|
||||
}}
|
||||
variables={["email", "firstName", "lastName"]}
|
||||
uploadImage={
|
||||
campaign.imageUploadSupported ? handleFileChange : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof campaignCreateSchema>;
|
||||
export type CampaignScheduleInput = z.infer<typeof campaignScheduleSchema>;
|
||||
|
||||
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<typeof campaignResponseSchema>;
|
||||
@@ -19,11 +19,314 @@ 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;
|
||||
|
||||
const CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES =
|
||||
CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.map((placeholder) => {
|
||||
const inner = placeholder.replace(/[{}]/g, "").trim();
|
||||
return new RegExp(`\\{\\{\\s*${inner}\\s*\\}}`, "i");
|
||||
});
|
||||
|
||||
const CONTACT_VARIABLE_REGEX =
|
||||
/\{\{\s*(?:contact\.)?(email|firstName|lastName)(?:,fallback=([^}]+))?\s*\}\}/gi;
|
||||
|
||||
function campaignHasUnsubscribePlaceholder(
|
||||
...sources: Array<string | null | undefined>
|
||||
) {
|
||||
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<string, string | null | undefined> = {
|
||||
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.html) {
|
||||
return { campaign, html: campaign.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<string, string> = {};
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -34,37 +337,21 @@ export async function sendCampaign(id: string) {
|
||||
throw new Error("Campaign not found");
|
||||
}
|
||||
|
||||
if (!campaign.content) {
|
||||
throw new Error("No content added for campaign");
|
||||
}
|
||||
|
||||
let jsonContent: Record<string, any>;
|
||||
|
||||
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 },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to parse campaign content");
|
||||
throw new Error("Failed to parse campaign content");
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user