add campaign api (#274)

This commit is contained in:
KM Koushik
2025-10-18 10:31:43 +11:00
committed by GitHub
parent e631f16c85
commit a5ca3b2f87
31 changed files with 2093 additions and 187 deletions
@@ -0,0 +1,3 @@
---
openapi: post /v1/campaigns
---
@@ -0,0 +1,3 @@
---
openapi: get /v1/campaigns/{campaignId}
---
@@ -0,0 +1,3 @@
---
openapi: post /v1/campaigns/{campaignId}/pause
---
@@ -0,0 +1,3 @@
---
openapi: post /v1/campaigns/{campaignId}/resume
---
@@ -0,0 +1,3 @@
---
openapi: post /v1/campaigns/{campaignId}/schedule
---
+525
View File
@@ -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",
"description": "Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am', 'next monday 10:30')"
},
"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",
"description": "Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am', 'next monday 10:30')"
},
"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"
]
}
}
}
}
}
}
} }
} }
} }
+10
View File
@@ -84,6 +84,16 @@
"api-reference/domains/verify-domain", "api-reference/domains/verify-domain",
"api-reference/domains/delete-domain" "api-reference/domains/delete-domain"
] ]
},
{
"group": "Campaigns",
"pages": [
"api-reference/campaigns/create-campaign",
"api-reference/campaigns/get-campaign",
"api-reference/campaigns/schedule-campaign",
"api-reference/campaigns/pause-campaign",
"api-reference/campaigns/resume-campaign"
]
} }
] ]
} }
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Campaign" ADD COLUMN "isApi" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -365,6 +365,7 @@ model Campaign {
bounced Int @default(0) bounced Int @default(0)
hardBounced Int @default(0) hardBounced Int @default(0)
complained Int @default(0) complained Int @default(0)
isApi Boolean @default(false)
status CampaignStatus @default(DRAFT) status CampaignStatus @default(DRAFT)
batchSize Int @default(500) batchSize Int @default(500)
batchWindowMinutes Int @default(0) batchWindowMinutes Int @default(0)
@@ -97,6 +97,7 @@ function CampaignEditor({
campaign: Campaign & { imageUploadSupported: boolean }; campaign: Campaign & { imageUploadSupported: boolean };
}) { }) {
const router = useRouter(); const router = useRouter();
const isApiCampaign = campaign.isApi;
const contactBooksQuery = api.contacts.getContactBooks.useQuery({}); const contactBooksQuery = api.contacts.getContactBooks.useQuery({});
const utils = api.useUtils(); const utils = api.useUtils();
@@ -124,6 +125,9 @@ function CampaignEditor({
const getUploadUrl = api.campaign.generateImagePresignedUrl.useMutation(); const getUploadUrl = api.campaign.generateImagePresignedUrl.useMutation();
function updateEditorContent() { function updateEditorContent() {
if (isApiCampaign) {
return;
}
updateCampaignMutation.mutate({ updateCampaignMutation.mutate({
campaignId: campaign.id, campaignId: campaign.id,
content: JSON.stringify(json), content: JSON.stringify(json),
@@ -142,8 +146,6 @@ function CampaignEditor({
); );
} }
console.log("file type: ", file.type);
const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({ const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
name: file.name, name: file.name,
type: file.type, type: file.type,
@@ -175,7 +177,12 @@ function CampaignEditor({
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]" className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
disabled={isApiCampaign}
readOnly={isApiCampaign}
onBlur={() => { onBlur={() => {
if (isApiCampaign) {
return;
}
if (name === campaign.name || !name) { if (name === campaign.name || !name) {
return; return;
} }
@@ -228,6 +235,9 @@ function CampaignEditor({
setSubject(e.target.value); setSubject(e.target.value);
}} }}
onBlur={() => { onBlur={() => {
if (isApiCampaign) {
return;
}
if (subject === campaign.subject || !subject) { if (subject === campaign.subject || !subject) {
return; 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" 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> <AccordionTrigger className="py-0"></AccordionTrigger>
</div> </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" 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>" placeholder="Friendly name<hello@example.com>"
onBlur={() => { onBlur={() => {
if (isApiCampaign) {
return;
}
if (from === campaign.from || !from) { if (from === campaign.from || !from) {
return; return;
} }
@@ -279,6 +294,8 @@ function CampaignEditor({
} }
); );
}} }}
disabled={isApiCampaign}
readOnly={isApiCampaign}
/> />
</div> </div>
<div className="flex items-center gap-4"> <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" 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" placeholder="hello@example.com"
onBlur={() => { onBlur={() => {
if (isApiCampaign) {
return;
}
if (replyTo === campaign.replyTo[0]) { if (replyTo === campaign.replyTo[0]) {
return; return;
} }
@@ -310,6 +330,8 @@ function CampaignEditor({
} }
); );
}} }}
disabled={isApiCampaign}
readOnly={isApiCampaign}
/> />
</div> </div>
@@ -324,6 +346,9 @@ function CampaignEditor({
setPreviewText(e.target.value); setPreviewText(e.target.value);
}} }}
onBlur={() => { onBlur={() => {
if (isApiCampaign) {
return;
}
if ( if (
previewText === campaign.previewText || previewText === campaign.previewText ||
!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" 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>
<div className=" flex items-center gap-2"> <div className=" flex items-center gap-2">
@@ -355,7 +382,11 @@ function CampaignEditor({
) : ( ) : (
<Select <Select
value={contactBookId ?? ""} value={contactBookId ?? ""}
disabled={isApiCampaign}
onValueChange={(val) => { onValueChange={(val) => {
if (isApiCampaign) {
return;
}
// Update the campaign's contactBookId // Update the campaign's contactBookId
updateCampaignMutation.mutate( updateCampaignMutation.mutate(
{ {
@@ -395,6 +426,12 @@ function CampaignEditor({
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
{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=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto"> <div className="w-[600px] mx-auto">
<Editor <Editor
@@ -411,6 +448,7 @@ function CampaignEditor({
/> />
</div> </div>
</div> </div>
)}
</div> </div>
</div> </div>
); );
+19 -12
View File
@@ -119,12 +119,14 @@ export const campaignRouter = createTRPCRouter({
subject: z.string().optional(), subject: z.string().optional(),
previewText: z.string().optional(), previewText: z.string().optional(),
content: z.string().optional(), content: z.string().optional(),
html: z.string().optional(),
contactBookId: z.string().optional(), contactBookId: z.string().optional(),
replyTo: z.string().array().optional(), replyTo: z.string().array().optional(),
}) })
) )
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => { .mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
const { campaignId, ...data } = input; const { html: htmlInput, ...data } = input;
const campaignId = campaignOld.id;
if (data.contactBookId) { if (data.contactBookId) {
const contactBook = await db.contactBook.findUnique({ const contactBook = await db.contactBook.findUnique({
where: { id: data.contactBookId }, where: { id: data.contactBookId },
@@ -143,22 +145,29 @@ export const campaignRouter = createTRPCRouter({
domainId = domain.id; domainId = domain.id;
} }
let html: string | null = null; let htmlToSave: string | undefined;
if (data.content) { if (data.content) {
const jsonContent = data.content ? JSON.parse(data.content) : null; const jsonContent = data.content ? JSON.parse(data.content) : null;
const renderer = new EmailRenderer(jsonContent); 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({ const campaign = await db.campaign.update({
where: { id: campaignId }, where: { id: campaignId },
data: { data: campaignUpdateData,
...data,
html,
domainId,
},
}); });
return campaign; return campaign;
}), }),
@@ -201,10 +210,7 @@ export const campaignRouter = createTRPCRouter({
teamId: team.id, teamId: team.id,
campaignId: campaign.id, campaignId: campaign.id,
}, },
orderBy: [ orderBy: [{ updatedAt: "desc" }, { createdAt: "desc" }],
{ updatedAt: "desc" },
{ createdAt: "desc" },
],
take: 10, take: 10,
select: { select: {
id: true, id: true,
@@ -240,6 +246,7 @@ export const campaignRouter = createTRPCRouter({
from: campaign.from, from: campaign.from,
subject: campaign.subject, subject: campaign.subject,
content: campaign.content, content: campaign.content,
html: campaign.html,
teamId: team.id, teamId: team.id,
domainId: campaign.domainId, domainId: campaign.domainId,
contactBookId: campaign.contactBookId, 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;
+12
View File
@@ -16,6 +16,11 @@ import verifyDomain from "./api/domains/verify-domain";
import getDomain from "./api/domains/get-domain"; import getDomain from "./api/domains/get-domain";
import deleteDomain from "./api/domains/delete-domain"; import deleteDomain from "./api/domains/delete-domain";
import sendBatch from "./api/emails/batch-email"; 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(); export const app = getApp();
@@ -42,4 +47,11 @@ getContacts(app);
upsertContact(app); upsertContact(app);
deleteContact(app); deleteContact(app);
/**Campaign related APIs */
createCampaign(app);
getCampaign(app);
scheduleCampaign(app);
pauseCampaign(app);
resumeCampaign(app);
export default 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>;
+324 -58
View File
@@ -19,11 +19,314 @@ import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
import { SuppressionService } from "./suppression-service"; import { SuppressionService } from "./suppression-service";
import { UnsendApiError } from "../public-api/api-error"; import { UnsendApiError } from "../public-api/api-error";
import {
validateApiKeyDomainAccess,
validateDomainFromEmail,
} from "./domain-service";
const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [ const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [
"{{unsend_unsubscribe_url}}", "{{unsend_unsubscribe_url}}",
"{{usesend_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) { export async function sendCampaign(id: string) {
let campaign = await db.campaign.findUnique({ let campaign = await db.campaign.findUnique({
@@ -34,37 +337,21 @@ export async function sendCampaign(id: string) {
throw new Error("Campaign not found"); throw new Error("Campaign not found");
} }
if (!campaign.content) { const prepared = await prepareCampaignHtml(campaign);
throw new Error("No content added for campaign"); campaign = prepared.campaign;
} const html = prepared.html;
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");
}
if (!campaign.contactBookId) { if (!campaign.contactBookId) {
throw new Error("No contact book found for campaign"); throw new Error("No contact book found for campaign");
} }
if (!campaign.html) { if (!html) {
throw new Error("No HTML content for campaign"); throw new Error("No HTML content for campaign");
} }
const unsubPlaceholderFound = CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.some( const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
(placeholder) => campaign.content,
campaign.content?.includes(placeholder) || html
campaign.html?.includes(placeholder)
); );
if (!unsubPlaceholderFound) { if (!unsubPlaceholderFound) {
@@ -115,26 +402,15 @@ export async function scheduleCampaign({
}); });
} }
if (!campaign.content) { let html: string;
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "No content added for campaign",
});
}
// Parse & render HTML (idempotent) similar to sendCampaign
try { try {
const jsonContent = JSON.parse(campaign.content); const prepared = await prepareCampaignHtml(campaign);
const renderer = new EmailRenderer(jsonContent); campaign = prepared.campaign;
const html = await renderer.render(); html = prepared.html;
campaign = await db.campaign.update({
where: { id: campaign.id },
data: { html },
});
} catch (err) { } catch (err) {
throw new UnsendApiError({ throw new UnsendApiError({
code: "BAD_REQUEST", 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({ throw new UnsendApiError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "No HTML content for campaign", message: "No HTML content for campaign",
}); });
} }
const unsubPlaceholderFound = CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.some( const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder(
(placeholder) => campaign.content,
campaign.content?.includes(placeholder) || html
campaign.html?.includes(placeholder)
); );
if (!unsubPlaceholderFound) { if (!unsubPlaceholderFound) {
throw new UnsendApiError({ throw new UnsendApiError({
@@ -429,8 +704,6 @@ type CampaignEmailJob = {
async function processContactEmail(jobData: CampaignEmailJob) { async function processContactEmail(jobData: CampaignEmailJob) {
const { contact, campaign, emailConfig } = jobData; const { contact, campaign, emailConfig } = jobData;
const jsonContent = JSON.parse(campaign.content || "{}");
const renderer = new EmailRenderer(jsonContent);
const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId); const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
const oneClickUnsubUrl = createOneClickUnsubUrl( const oneClickUnsubUrl = createOneClickUnsubUrl(
@@ -467,17 +740,10 @@ async function processContactEmail(jobData: CampaignEmailJob) {
// Check if the contact's email (TO recipient) is suppressed // Check if the contact's email (TO recipient) is suppressed
const isContactSuppressed = filteredToEmails.length === 0; const isContactSuppressed = filteredToEmails.length === 0;
const html = await renderer.render({ const html = await renderCampaignHtmlForContact({
shouldReplaceVariableValues: true, campaign,
variableValues: { contact,
email: contact.email, unsubscribeUrl,
firstName: contact.firstName,
lastName: contact.lastName,
},
linkValues: {
"{{unsend_unsubscribe_url}}": unsubscribeUrl,
"{{usesend_unsubscribe_url}}": unsubscribeUrl,
},
}); });
if (isContactSuppressed) { if (isContactSuppressed) {
+31 -1
View File
@@ -37,6 +37,29 @@ resp, _ = client.emails.send(payload={
"html": "<strong>Hi!</strong>", "html": "<strong>Hi!</strong>",
}) })
# 3) Campaigns
campaign_payload: types.CampaignCreate = {
"name": "Welcome Series",
"subject": "Welcome to our service!",
"html": "<p>Thanks for joining us!</p>",
"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: # Toggle behavior if desired:
# - raise_on_error=False: return (None, error_dict) instead of raising # - raise_on_error=False: return (None, error_dict) instead of raising
# No model parsing occurs; methods return plain dicts following the typed shapes. # 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. 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 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. - Endpoint methods accept TypedDict payloads or plain dicts via the `payload=` keyword.
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "usesend" name = "usesend"
version = "0.2.6" version = "0.2.7"
description = "Python SDK for the UseSend API" description = "Python SDK for the UseSend API"
authors = ["UseSend"] authors = ["UseSend"]
license = "MIT" license = "MIT"
+2 -1
View File
@@ -2,6 +2,7 @@
from .usesend import UseSend, UseSendHTTPError from .usesend import UseSend, UseSendHTTPError
from .domains import Domains # type: ignore from .domains import Domains # type: ignore
from .campaigns import Campaigns # type: ignore
from . import types from . import types
__all__ = ["UseSend", "UseSendHTTPError", "types", "Domains"] __all__ = ["UseSend", "UseSendHTTPError", "types", "Domains", "Campaigns"]
+68
View File
@@ -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
+202 -96
View File
@@ -15,14 +15,14 @@ from typing_extensions import NotRequired, Required, Literal
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DomainStatus = Literal[ DomainStatus = Literal[
'NOT_STARTED', "NOT_STARTED",
'PENDING', "PENDING",
'SUCCESS', "SUCCESS",
'FAILED', "FAILED",
'TEMPORARY_FAILURE', "TEMPORARY_FAILURE",
] ]
DNSRecordType = Literal['MX', 'TXT'] DNSRecordType = Literal["MX", "TXT"]
class DNSRecord(TypedDict, total=False): class DNSRecord(TypedDict, total=False):
@@ -99,24 +99,25 @@ class DomainDeleteResponse(TypedDict):
success: bool success: bool
message: str message: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Emails # Emails
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
EmailEventStatus = Literal[ EmailEventStatus = Literal[
'SCHEDULED', "SCHEDULED",
'QUEUED', "QUEUED",
'SENT', "SENT",
'DELIVERY_DELAYED', "DELIVERY_DELAYED",
'BOUNCED', "BOUNCED",
'REJECTED', "REJECTED",
'RENDERING_FAILURE', "RENDERING_FAILURE",
'DELIVERED', "DELIVERED",
'OPENED', "OPENED",
'CLICKED', "CLICKED",
'COMPLAINED', "COMPLAINED",
'FAILED', "FAILED",
'CANCELLED', "CANCELLED",
] ]
@@ -128,22 +129,22 @@ class EmailEvent(TypedDict, total=False):
Email = TypedDict( Email = TypedDict(
'Email', "Email",
{ {
'id': str, "id": str,
'teamId': float, "teamId": float,
'to': Union[str, List[str]], "to": Union[str, List[str]],
'replyTo': NotRequired[Union[str, List[str]]], "replyTo": NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]], "cc": NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]], "bcc": NotRequired[Union[str, List[str]]],
'from': str, "from": str,
'subject': str, "subject": str,
'html': str, "html": str,
'text': str, "text": str,
'createdAt': str, "createdAt": str,
'updatedAt': str, "updatedAt": str,
'emailEvents': List[EmailEvent], "emailEvents": List[EmailEvent],
} },
) )
@@ -157,40 +158,40 @@ class EmailUpdateResponse(TypedDict, total=False):
EmailLatestStatus = Literal[ EmailLatestStatus = Literal[
'SCHEDULED', "SCHEDULED",
'QUEUED', "QUEUED",
'SENT', "SENT",
'DELIVERY_DELAYED', "DELIVERY_DELAYED",
'BOUNCED', "BOUNCED",
'REJECTED', "REJECTED",
'RENDERING_FAILURE', "RENDERING_FAILURE",
'DELIVERED', "DELIVERED",
'OPENED', "OPENED",
'CLICKED', "CLICKED",
'COMPLAINED', "COMPLAINED",
'FAILED', "FAILED",
'CANCELLED', "CANCELLED",
] ]
EmailListItem = TypedDict( EmailListItem = TypedDict(
'EmailListItem', "EmailListItem",
{ {
'id': str, "id": str,
'to': Union[str, List[str]], "to": Union[str, List[str]],
'replyTo': NotRequired[Union[str, List[str]]], "replyTo": NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]], "cc": NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]], "bcc": NotRequired[Union[str, List[str]]],
'from': str, "from": str,
'subject': str, "subject": str,
'html': str, "html": str,
'text': str, "text": str,
'createdAt': str, "createdAt": str,
'updatedAt': str, "updatedAt": str,
'latestStatus': EmailLatestStatus, "latestStatus": EmailLatestStatus,
'scheduledAt': str, "scheduledAt": str,
'domainId': float, "domainId": float,
} },
) )
@@ -205,23 +206,23 @@ class Attachment(TypedDict):
EmailCreate = TypedDict( EmailCreate = TypedDict(
'EmailCreate', "EmailCreate",
{ {
'to': Required[Union[str, List[str]]], "to": Required[Union[str, List[str]]],
'from': Required[str], "from": Required[str],
'subject': NotRequired[str], "subject": NotRequired[str],
'templateId': NotRequired[str], "templateId": NotRequired[str],
'variables': NotRequired[Dict[str, str]], "variables": NotRequired[Dict[str, str]],
'replyTo': NotRequired[Union[str, List[str]]], "replyTo": NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]], "cc": NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]], "bcc": NotRequired[Union[str, List[str]]],
'text': NotRequired[str], "text": NotRequired[str],
'html': NotRequired[str], "html": NotRequired[str],
'attachments': NotRequired[List[Attachment]], "attachments": NotRequired[List[Attachment]],
'scheduledAt': NotRequired[Union[datetime, str]], "scheduledAt": NotRequired[Union[datetime, str]],
'inReplyToId': NotRequired[str], "inReplyToId": NotRequired[str],
'headers': NotRequired[Dict[str, str]], "headers": NotRequired[Dict[str, str]],
} },
) )
@@ -230,23 +231,23 @@ class EmailCreateResponse(TypedDict, total=False):
EmailBatchItem = TypedDict( EmailBatchItem = TypedDict(
'EmailBatchItem', "EmailBatchItem",
{ {
'to': Required[Union[str, List[str]]], "to": Required[Union[str, List[str]]],
'from': Required[str], "from": Required[str],
'subject': NotRequired[str], "subject": NotRequired[str],
'templateId': NotRequired[str], "templateId": NotRequired[str],
'variables': NotRequired[Dict[str, str]], "variables": NotRequired[Dict[str, str]],
'replyTo': NotRequired[Union[str, List[str]]], "replyTo": NotRequired[Union[str, List[str]]],
'cc': NotRequired[Union[str, List[str]]], "cc": NotRequired[Union[str, List[str]]],
'bcc': NotRequired[Union[str, List[str]]], "bcc": NotRequired[Union[str, List[str]]],
'text': NotRequired[str], "text": NotRequired[str],
'html': NotRequired[str], "html": NotRequired[str],
'attachments': NotRequired[List[Attachment]], "attachments": NotRequired[List[Attachment]],
'scheduledAt': NotRequired[Union[datetime, str]], "scheduledAt": NotRequired[Union[datetime, str]],
'inReplyToId': NotRequired[str], "inReplyToId": NotRequired[str],
'headers': NotRequired[Dict[str, str]], "headers": NotRequired[Dict[str, str]],
} },
) )
@@ -269,6 +270,7 @@ class EmailCancelResponse(TypedDict, total=False):
# Contacts # Contacts
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class ContactCreate(TypedDict, total=False): class ContactCreate(TypedDict, total=False):
email: str email: str
firstName: Optional[str] firstName: Optional[str]
@@ -335,11 +337,115 @@ class ContactDeleteResponse(TypedDict):
success: bool 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 # Common
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class APIError(TypedDict): class APIError(TypedDict):
code: str code: str
message: str message: str
+2
View File
@@ -72,6 +72,7 @@ class UseSend:
self.emails = Emails(self) self.emails = Emails(self)
self.contacts = Contacts(self) self.contacts = Contacts(self)
self.domains = Domains(self) self.domains = Domains(self)
self.campaigns = Campaigns(self)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internal request helper # Internal request helper
@@ -125,3 +126,4 @@ class UseSend:
from .emails import Emails # noqa: E402 pylint: disable=wrong-import-position from .emails import Emails # noqa: E402 pylint: disable=wrong-import-position
from .contacts import Contacts # 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 .domains import Domains # type: ignore # noqa: E402
from .campaigns import Campaigns # type: ignore # noqa: E402
+35
View File
@@ -49,3 +49,38 @@ usesend.emails.send({
text: "useSend is the best open source product to send emails", 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: "<h1>Welcome!</h1><p>Thanks for joining us.</p>",
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);
```
+1
View File
@@ -1,2 +1,3 @@
export { UseSend } from "./src/usesend"; export { UseSend } from "./src/usesend";
export { UseSend as Unsend } from "./src/usesend"; // deprecated alias export { UseSend as Unsend } from "./src/usesend"; // deprecated alias
export { Campaigns } from "./src/campaign";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "usesend-js", "name": "usesend-js",
"version": "1.5.5", "version": "1.5.6",
"description": "", "description": "",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
+94
View File
@@ -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<CreateCampaignResponse> {
const data = await this.usesend.post<CreateCampaignResponseSuccess>(
`/campaigns`,
payload,
);
return data;
}
async get(campaignId: string): Promise<GetCampaignResponse> {
const data = await this.usesend.get<GetCampaignResponseSuccess>(
`/campaigns/${campaignId}`,
);
return data;
}
async schedule(
campaignId: string,
payload: ScheduleCampaignPayload,
): Promise<ScheduleCampaignResponse> {
const data = await this.usesend.post<ScheduleCampaignResponseSuccess>(
`/campaigns/${campaignId}/schedule`,
payload,
);
return data;
}
async pause(campaignId: string): Promise<CampaignActionResponse> {
const data = await this.usesend.post<CampaignActionResponseSuccess>(
`/campaigns/${campaignId}/pause`,
{},
);
return data;
}
async resume(campaignId: string): Promise<CampaignActionResponse> {
const data = await this.usesend.post<CampaignActionResponseSuccess>(
`/campaigns/${campaignId}/resume`,
{},
);
return data;
}
}
+2
View File
@@ -2,6 +2,7 @@ import { ErrorResponse } from "../types";
import { Contacts } from "./contact"; import { Contacts } from "./contact";
import { Emails } from "./email"; import { Emails } from "./email";
import { Domains } from "./domain"; import { Domains } from "./domain";
import { Campaigns } from "./campaign";
const defaultBaseUrl = "https://app.usesend.com"; const defaultBaseUrl = "https://app.usesend.com";
// eslint-disable-next-line turbo/no-undeclared-env-vars // eslint-disable-next-line turbo/no-undeclared-env-vars
@@ -18,6 +19,7 @@ export class UseSend {
readonly emails = new Emails(this); readonly emails = new Emails(this);
readonly domains = new Domains(this); readonly domains = new Domains(this);
readonly contacts = new Contacts(this); readonly contacts = new Contacts(this);
readonly campaigns = new Campaigns(this);
url = baseUrl; url = baseUrl;
constructor( constructor(
+276
View File
@@ -959,6 +959,282 @@ export interface paths {
}; };
trace?: never; 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<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {