add campaign api (#274)
This commit is contained in:
@@ -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
|
||||||
|
---
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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,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"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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,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(
|
||||||
|
|||||||
Vendored
+276
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user