diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index ce39eb17..c2d886dc 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -2085,6 +2085,39 @@ "type": "integer", "minimum": 1, "maximum": 100000 + }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": ["all_at_once"] + } + }, + "required": ["strategy"] + }, + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": ["gradual"] + }, + "batchPercentage": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "interval": { + "type": "string", + "enum": ["minute", "hour"] + } + }, + "required": ["strategy", "batchPercentage", "interval"] + } + ] } }, "required": ["name", "from", "subject", "contactBookId"] @@ -2116,6 +2149,29 @@ }, "batchSize": { "type": "integer" }, "batchWindowMinutes": { "type": "integer" }, + "deliveryMode": { + "type": "string", + "enum": ["ALL_AT_ONCE", "GRADUAL"] + }, + "deliveryBatchPercentage": { + "type": "integer", + "nullable": true + }, + "deliveryIntervalMinutes": { + "type": "integer", + "nullable": true + }, + "deliveryBatchSize": { + "type": "integer", + "nullable": true + }, + "currentDeliveryBatch": { "type": "integer" }, + "deliveryBatchProcessed": { "type": "integer" }, + "nextDeliveryAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "total": { "type": "integer" }, "sent": { "type": "integer" }, "delivered": { "type": "integer" }, @@ -2147,6 +2203,13 @@ "scheduledAt", "batchSize", "batchWindowMinutes", + "deliveryMode", + "deliveryBatchPercentage", + "deliveryIntervalMinutes", + "deliveryBatchSize", + "currentDeliveryBatch", + "deliveryBatchProcessed", + "nextDeliveryAt", "total", "sent", "delivered", @@ -2302,6 +2365,29 @@ }, "batchSize": { "type": "integer" }, "batchWindowMinutes": { "type": "integer" }, + "deliveryMode": { + "type": "string", + "enum": ["ALL_AT_ONCE", "GRADUAL"] + }, + "deliveryBatchPercentage": { + "type": "integer", + "nullable": true + }, + "deliveryIntervalMinutes": { + "type": "integer", + "nullable": true + }, + "deliveryBatchSize": { + "type": "integer", + "nullable": true + }, + "currentDeliveryBatch": { "type": "integer" }, + "deliveryBatchProcessed": { "type": "integer" }, + "nextDeliveryAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "total": { "type": "integer" }, "sent": { "type": "integer" }, "delivered": { "type": "integer" }, @@ -2333,6 +2419,13 @@ "scheduledAt", "batchSize", "batchWindowMinutes", + "deliveryMode", + "deliveryBatchPercentage", + "deliveryIntervalMinutes", + "deliveryBatchSize", + "currentDeliveryBatch", + "deliveryBatchProcessed", + "nextDeliveryAt", "total", "sent", "delivered", @@ -2391,6 +2484,29 @@ }, "batchSize": { "type": "integer" }, "batchWindowMinutes": { "type": "integer" }, + "deliveryMode": { + "type": "string", + "enum": ["ALL_AT_ONCE", "GRADUAL"] + }, + "deliveryBatchPercentage": { + "type": "integer", + "nullable": true + }, + "deliveryIntervalMinutes": { + "type": "integer", + "nullable": true + }, + "deliveryBatchSize": { + "type": "integer", + "nullable": true + }, + "currentDeliveryBatch": { "type": "integer" }, + "deliveryBatchProcessed": { "type": "integer" }, + "nextDeliveryAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "total": { "type": "integer" }, "sent": { "type": "integer" }, "delivered": { "type": "integer" }, @@ -2422,6 +2538,13 @@ "scheduledAt", "batchSize", "batchWindowMinutes", + "deliveryMode", + "deliveryBatchPercentage", + "deliveryIntervalMinutes", + "deliveryBatchSize", + "currentDeliveryBatch", + "deliveryBatchProcessed", + "nextDeliveryAt", "total", "sent", "delivered", @@ -2473,6 +2596,39 @@ "type": "integer", "minimum": 1, "maximum": 100000 + }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": ["all_at_once"] + } + }, + "required": ["strategy"] + }, + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": ["gradual"] + }, + "batchPercentage": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "interval": { + "type": "string", + "enum": ["minute", "hour"] + } + }, + "required": ["strategy", "batchPercentage", "interval"] + } + ] } } } diff --git a/apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql b/apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql new file mode 100644 index 00000000..c3181c45 --- /dev/null +++ b/apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql @@ -0,0 +1,26 @@ +-- CreateEnum +CREATE TYPE "CampaignDeliveryMode" AS ENUM ('ALL_AT_ONCE', 'GRADUAL'); + +-- CreateEnum +CREATE TYPE "CampaignRecipientStatus" AS ENUM ('PENDING', 'PROCESSING', 'QUEUED', 'SUPPRESSED', 'SKIPPED', 'FAILED'); + +-- AlterTable +ALTER TABLE "CampaignEmail" ALTER COLUMN "emailId" DROP NOT NULL; +-- PostgreSQL 11+ stores a constant default as metadata instead of rewriting +-- every existing row. Existing recipients are already queued, while new rows +-- created by the gradual delivery worker should default to pending. +ALTER TABLE "CampaignEmail" ADD COLUMN "status" "CampaignRecipientStatus" NOT NULL DEFAULT 'QUEUED'; +ALTER TABLE "CampaignEmail" ADD COLUMN "processedAt" TIMESTAMP(3); +ALTER TABLE "CampaignEmail" ALTER COLUMN "status" SET DEFAULT 'PENDING'; + +-- AlterTable +ALTER TABLE "Campaign" ADD COLUMN "deliveryMode" "CampaignDeliveryMode" NOT NULL DEFAULT 'ALL_AT_ONCE'; +ALTER TABLE "Campaign" ADD COLUMN "deliveryBatchPercentage" INTEGER; +ALTER TABLE "Campaign" ADD COLUMN "deliveryIntervalMinutes" INTEGER; +ALTER TABLE "Campaign" ADD COLUMN "deliveryBatchSize" INTEGER; +ALTER TABLE "Campaign" ADD COLUMN "currentDeliveryBatch" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "Campaign" ADD COLUMN "deliveryBatchProcessed" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "Campaign" ADD COLUMN "nextDeliveryAt" TIMESTAMP(3); +ALTER TABLE "Campaign" ADD COLUMN "audienceCapturedAt" TIMESTAMP(3); +ALTER TABLE "Campaign" ADD COLUMN "audiencePreparedAt" TIMESTAMP(3); +ALTER TABLE "Campaign" ADD COLUMN "pausedAt" TIMESTAMP(3); diff --git a/apps/web/prisma/migrations/20260721170000_add_campaign_email_recipient_status_index/migration.sql b/apps/web/prisma/migrations/20260721170000_add_campaign_email_recipient_status_index/migration.sql new file mode 100644 index 00000000..c1bf8db6 --- /dev/null +++ b/apps/web/prisma/migrations/20260721170000_add_campaign_email_recipient_status_index/migration.sql @@ -0,0 +1,4 @@ +-- CreateIndex +-- Keep this as the migration's only SQL statement so PostgreSQL can build the +-- index without blocking inserts or updates. +CREATE INDEX CONCURRENTLY "CampaignEmail_campaignId_status_contactId_idx" ON "CampaignEmail"("campaignId", "status", "contactId"); diff --git a/apps/web/prisma/migrations/20260721180000_add_campaign_next_delivery_index/migration.sql b/apps/web/prisma/migrations/20260721180000_add_campaign_next_delivery_index/migration.sql new file mode 100644 index 00000000..43292423 --- /dev/null +++ b/apps/web/prisma/migrations/20260721180000_add_campaign_next_delivery_index/migration.sql @@ -0,0 +1,4 @@ +-- CreateIndex +-- Keep this as the migration's only SQL statement so PostgreSQL can build the +-- index without blocking inserts or updates. +CREATE INDEX CONCURRENTLY "Campaign_status_nextDeliveryAt_idx" ON "Campaign"("status", "nextDeliveryAt"); diff --git a/apps/web/prisma/migrations/20260721190000_add_campaign_email_status_index/migration.sql b/apps/web/prisma/migrations/20260721190000_add_campaign_email_status_index/migration.sql new file mode 100644 index 00000000..f1bc1458 --- /dev/null +++ b/apps/web/prisma/migrations/20260721190000_add_campaign_email_status_index/migration.sql @@ -0,0 +1,4 @@ +-- CreateIndex +-- Keep this as the migration's only SQL statement so Prisma runs it outside a +-- transaction and PostgreSQL can build it without blocking inserts or updates. +CREATE INDEX CONCURRENTLY "Email_campaignId_latestStatus_idx" ON "Email"("campaignId", "latestStatus"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 1492adf8..b6283407 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -267,16 +267,20 @@ model Email { emailEvents EmailEvent[] @@index([campaignId, contactId]) + @@index([campaignId, latestStatus]) @@index([createdAt(sort: Desc)]) } model CampaignEmail { - campaignId String - contactId String - emailId String - createdAt DateTime @default(now()) + campaignId String + contactId String + emailId String? + status CampaignRecipientStatus @default(PENDING) + processedAt DateTime? + createdAt DateTime @default(now()) @@id([campaignId, contactId]) + @@index([campaignId, status, contactId]) } model EmailEvent { @@ -349,43 +353,68 @@ enum CampaignStatus { SENT } +enum CampaignDeliveryMode { + ALL_AT_ONCE + GRADUAL +} + +enum CampaignRecipientStatus { + PENDING + PROCESSING + QUEUED + SUPPRESSED + SKIPPED + FAILED +} + model Campaign { - id String @id @default(cuid()) - name String - teamId Int - from String - cc String[] - bcc String[] - replyTo String[] - domainId Int - subject String - previewText String? - html String? - content String? - contactBookId String? - scheduledAt DateTime? - total Int @default(0) - sent Int @default(0) - delivered Int @default(0) - opened Int @default(0) - clicked Int @default(0) - unsubscribed Int @default(0) - bounced Int @default(0) - hardBounced Int @default(0) - complained Int @default(0) - isApi Boolean @default(false) - status CampaignStatus @default(DRAFT) - batchSize Int @default(500) - batchWindowMinutes Int @default(0) - lastCursor String? - lastSentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + teamId Int + from String + cc String[] + bcc String[] + replyTo String[] + domainId Int + subject String + previewText String? + html String? + content String? + contactBookId String? + scheduledAt DateTime? + total Int @default(0) + sent Int @default(0) + delivered Int @default(0) + opened Int @default(0) + clicked Int @default(0) + unsubscribed Int @default(0) + bounced Int @default(0) + hardBounced Int @default(0) + complained Int @default(0) + isApi Boolean @default(false) + status CampaignStatus @default(DRAFT) + batchSize Int @default(500) + batchWindowMinutes Int @default(0) + lastCursor String? + lastSentAt DateTime? + deliveryMode CampaignDeliveryMode @default(ALL_AT_ONCE) + deliveryBatchPercentage Int? + deliveryIntervalMinutes Int? + deliveryBatchSize Int? + currentDeliveryBatch Int @default(0) + deliveryBatchProcessed Int @default(0) + nextDeliveryAt DateTime? + audienceCapturedAt DateTime? + audiencePreparedAt DateTime? + pausedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@index([createdAt(sort: Desc)]) @@index([status, scheduledAt]) + @@index([status, nextDeliveryAt]) } model Template { diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx index 93aeaf66..b8768ee1 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx @@ -22,6 +22,7 @@ import { Button } from "@usesend/ui/src/button"; import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card"; import { EmailStatusBadge } from "../../emails/email-status-badge"; import { AnimatePresence, motion } from "framer-motion"; +import { Clock3, Send } from "lucide-react"; export default function CampaignDetailsPage({ params, @@ -38,6 +39,7 @@ export default function CampaignDetailsPage({ if (!c) return false; if ( + c.status === CampaignStatus.SCHEDULED || c.status === CampaignStatus.RUNNING || c.status === CampaignStatus.PAUSED ) { @@ -45,7 +47,7 @@ export default function CampaignDetailsPage({ } return false; }, - } + }, ); const { data: latestEmails, isLoading: latestEmailsLoading } = @@ -53,9 +55,38 @@ export default function CampaignDetailsPage({ { campaignId: campaignId }, { refetchInterval: 5000, - } + }, ); + const { data: deliveryProgress } = api.campaign.getDeliveryProgress.useQuery( + { campaignId }, + { + refetchInterval: (query) => { + if ( + campaign?.status === CampaignStatus.SCHEDULED || + campaign?.status === CampaignStatus.RUNNING + ) { + return 5000; + } + + const progress = query.state.data; + if ( + campaign?.status === CampaignStatus.PAUSED && + progress?.processing + ) { + return 5000; + } + + const needsFinalSnapshot = + campaign?.status === CampaignStatus.SENT && + progress != null && + progress.pending > 0; + + return needsFinalSnapshot ? 5000 : false; + }, + }, + ); + if (isLoading) { return (
+ Enter a whole number from 1 to 50. +
+ ) : null} ++ Couldn't load the audience preview. +
+ ++ Based on the current subscribed audience. The final + audience is captured when sending starts. +
++ No subscribed recipients to preview yet. +
+ ) : null} +Hello
", + delivery: { + strategy: "gradual", + batchPercentage: 10, + interval: "hour", + }, + }); + + expect(result.success).toBe(true); + }); + + it.each([0, 51, 10.5])( + "rejects an invalid gradual percentage of %s", + (batchPercentage) => { + const result = campaignScheduleSchema.safeParse({ + delivery: { + strategy: "gradual", + batchPercentage, + interval: "minute", + }, + }); + + expect(result.success).toBe(false); + }, + ); + + it("accepts the all-at-once delivery strategy", () => { + const result = campaignScheduleSchema.safeParse({ + delivery: { strategy: "all_at_once" }, + }); + + expect(result.success).toBe(true); + }); + + it("rejects gradual-only fields for all-at-once delivery", () => { + const result = campaignScheduleSchema.safeParse({ + delivery: { + strategy: "all_at_once", + batchPercentage: 10, + interval: "hour", + }, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 5a109993..70ddb3a4 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -3,9 +3,11 @@ import { db } from "../db"; import { createHash } from "crypto"; import { env } from "~/env"; import { - Campaign, - Contact, + type Campaign, + type Contact, + type Email, EmailStatus, + Prisma, UnsubscribeReason, } from "@prisma/client"; import { EmailQueueService } from "./email-queue-service"; @@ -31,6 +33,46 @@ import { } from "../utils/contact-variable-replacement"; import { updateContactSubscription } from "./contact-service"; import { getCampaignUnsubscribeVariableValues } from "~/lib/constants/campaign"; +import { + calculateGradualDelivery, + GRADUAL_DELIVERY_INTERVAL_MINUTES, +} from "~/lib/campaign-delivery"; +import type { GradualDeliveryInterval } from "~/lib/campaign-delivery"; + +const GRADUAL_DELIVERY_INTERNAL_BATCH_SIZE = 500; +const CAMPAIGN_RECIPIENT_CLAIM_TIMEOUT_MS = 60 * 60 * 1000; +const CAMPAIGN_AUDIENCE_PREPARATION_TIMEOUT_MS = 30 * 60 * 1000; + +type ClaimedCampaignRecipient = { + contactId: string; + claimProcessedAt: Date; +}; + +export type CampaignDeliveryInput = + | { strategy: "ALL_AT_ONCE" } + | { + strategy: "GRADUAL"; + batchPercentage: number; + interval: GradualDeliveryInterval; + }; + +function assertCampaignCanBeScheduled(status: Campaign["status"]) { + if (status === "SENT") { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: + "Completed campaigns cannot be scheduled again. Duplicate the campaign to send it again", + }); + } + + if (status !== "DRAFT" && status !== "SCHEDULED") { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: + "Delivery settings cannot be changed after a campaign has started", + }); + } +} const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [ "{{unsend_unsubscribe_url}}", @@ -69,6 +111,82 @@ function sanitizeAddressList(addresses?: string | string[]) { .filter((address) => address.length > 0); } +function getCampaignDeliveryData({ + delivery, + audienceSize, + startsAt, +}: { + delivery?: CampaignDeliveryInput; + audienceSize: number; + startsAt: Date; +}) { + if (!delivery || delivery.strategy === "ALL_AT_ONCE") { + return { + deliveryMode: "ALL_AT_ONCE" as const, + deliveryBatchPercentage: null, + deliveryIntervalMinutes: null, + deliveryBatchSize: null, + }; + } + + const intervalMinutes = GRADUAL_DELIVERY_INTERVAL_MINUTES[delivery.interval]; + const estimate = calculateGradualDelivery({ + audienceSize, + batchPercentage: delivery.batchPercentage, + intervalMinutes, + startsAt, + }); + + return { + deliveryMode: "GRADUAL" as const, + deliveryBatchPercentage: delivery.batchPercentage, + deliveryIntervalMinutes: intervalMinutes, + deliveryBatchSize: estimate.batchSize, + }; +} + +function getCampaignDraftDeliveryData(delivery?: CampaignDeliveryInput) { + if (!delivery || delivery.strategy === "ALL_AT_ONCE") { + return { + deliveryMode: "ALL_AT_ONCE" as const, + deliveryBatchPercentage: null, + deliveryIntervalMinutes: null, + deliveryBatchSize: null, + }; + } + + return { + deliveryMode: "GRADUAL" as const, + deliveryBatchPercentage: delivery.batchPercentage, + deliveryIntervalMinutes: + GRADUAL_DELIVERY_INTERVAL_MINUTES[delivery.interval], + deliveryBatchSize: null, + }; +} + +function getStoredCampaignDelivery(campaign: Campaign): CampaignDeliveryInput { + if (campaign.deliveryMode !== "GRADUAL") { + return { strategy: "ALL_AT_ONCE" }; + } + + const interval = Object.entries(GRADUAL_DELIVERY_INTERVAL_MINUTES).find( + ([, minutes]) => minutes === campaign.deliveryIntervalMinutes, + )?.[0] as GradualDeliveryInterval | undefined; + + if (!campaign.deliveryBatchPercentage || !interval) { + throw new UnsendApiError({ + code: "BAD_REQUEST", + message: "Gradual delivery configuration is incomplete", + }); + } + + return { + strategy: "GRADUAL", + batchPercentage: campaign.deliveryBatchPercentage, + interval, + }; +} + async function prepareCampaignHtml( campaign: Campaign, ): Promise<{ campaign: Campaign; html: string }> { @@ -178,6 +296,7 @@ export async function createCampaignFromApi({ cc, bcc, batchSize, + delivery, }: { teamId: number; apiKeyId?: number; @@ -192,6 +311,7 @@ export async function createCampaignFromApi({ cc?: string | string[]; bcc?: string | string[]; batchSize?: number; + delivery?: CampaignDeliveryInput; }) { if (!content && !html) { throw new UnsendApiError({ @@ -276,6 +396,7 @@ export async function createCampaignFromApi({ bcc: sanitizeAddressList(bcc), teamId, domainId: domain.id, + ...getCampaignDraftDeliveryData(delivery), ...(typeof batchSize === "number" ? { batchSize } : {}), }, }); @@ -305,6 +426,13 @@ export async function getCampaignForTeam({ scheduledAt: true, batchSize: true, batchWindowMinutes: true, + deliveryMode: true, + deliveryBatchPercentage: true, + deliveryIntervalMinutes: true, + deliveryBatchSize: true, + currentDeliveryBatch: true, + deliveryBatchProcessed: true, + nextDeliveryAt: true, total: true, sent: true, delivered: true, @@ -390,11 +518,13 @@ export async function scheduleCampaign({ teamId, scheduledAt: scheduledAtInput, batchSize, + delivery, }: { campaignId: string; teamId: number; scheduledAt?: Date | string; batchSize?: number; + delivery?: CampaignDeliveryInput; }) { let campaign = await db.campaign.findUnique({ where: { id: campaignId, teamId }, @@ -406,6 +536,8 @@ export async function scheduleCampaign({ }); } + assertCampaignCanBeScheduled(campaign.status); + let html: string; try { const prepared = await prepareCampaignHtml(campaign); @@ -461,18 +593,55 @@ export async function scheduleCampaign({ : new Date(scheduledAtInput) : new Date(); - const shouldResetCursor = - campaign.status === "DRAFT" || campaign.status === "SENT"; + const shouldResetCursor = campaign.status === "DRAFT"; - await db.campaign.update({ - where: { id: campaign.id }, - data: { - status: "SCHEDULED", - scheduledAt, - total, - ...(batchSize ? { batchSize } : {}), - ...(shouldResetCursor ? { lastCursor: null } : {}), - }, + const deliveryData = getCampaignDeliveryData({ + delivery: delivery ?? getStoredCampaignDelivery(campaign), + audienceSize: total, + startsAt: scheduledAt, + }); + + await db.$transaction(async (tx) => { + const lockedCampaign = await tx.$queryRaw< + Array<{ status: Campaign["status"] }> + >` + SELECT "status" + FROM "Campaign" + WHERE "id" = ${campaign.id} + AND "teamId" = ${teamId} + FOR UPDATE + `; + + if (lockedCampaign.length === 0) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Campaign not found", + }); + } + + assertCampaignCanBeScheduled(lockedCampaign[0]!.status); + + await tx.campaignEmail.deleteMany({ + where: { campaignId: campaign.id }, + }); + + await tx.campaign.update({ + where: { id: campaign.id }, + data: { + status: "SCHEDULED", + scheduledAt, + total, + ...deliveryData, + currentDeliveryBatch: 0, + deliveryBatchProcessed: 0, + nextDeliveryAt: null, + audienceCapturedAt: null, + audiencePreparedAt: null, + pausedAt: null, + ...(batchSize ? { batchSize } : {}), + ...(shouldResetCursor ? { lastCursor: null } : {}), + }, + }); }); return { ok: true }; @@ -496,9 +665,17 @@ export async function pauseCampaign({ }); } - await db.campaign.update({ - where: { id: campaignId }, - data: { status: "PAUSED" }, + if (campaign.status === "PAUSED") { + return { ok: true }; + } + + await db.campaign.updateMany({ + where: { + id: campaignId, + teamId, + status: campaign.status, + }, + data: { status: "PAUSED", pausedAt: new Date() }, }); return { ok: true }; @@ -522,15 +699,31 @@ export async function resumeCampaign({ }); } - if (campaign.scheduledAt && campaign.scheduledAt.getTime() > Date.now()) { + const now = new Date(); + const pausedDurationMs = campaign.pausedAt + ? Math.max(0, now.getTime() - campaign.pausedAt.getTime()) + : 0; + const shiftedNextDeliveryAt = campaign.nextDeliveryAt + ? new Date(campaign.nextDeliveryAt.getTime() + pausedDurationMs) + : null; + + if (campaign.scheduledAt && campaign.scheduledAt.getTime() > now.getTime()) { await db.campaign.update({ where: { id: campaignId }, - data: { status: "SCHEDULED" }, + data: { + status: "SCHEDULED", + pausedAt: null, + nextDeliveryAt: shiftedNextDeliveryAt, + }, }); } else { await db.campaign.update({ where: { id: campaignId }, - data: { status: "RUNNING" }, + data: { + status: "RUNNING", + pausedAt: null, + nextDeliveryAt: shiftedNextDeliveryAt, + }, }); } @@ -727,6 +920,7 @@ export async function deleteCampaign(id: string, teamId: number) { type CampaignEmailJob = { contact: Contact; campaign: Campaign; + claimProcessedAt: Date; allowedVariables: string[]; emailConfig: { from: string; @@ -745,6 +939,7 @@ type CampaignEmailJob = { type CampaignContactFailureInput = { contact: PickHello
", + "delivery": delivery, + } + ) + client.campaigns.schedule( + "campaign_123", + {"delivery": delivery}, + ) + + assert session.calls[0]["json"]["delivery"] == delivery + assert session.calls[1]["json"]["delivery"] == delivery + assert "deliveryMode" in types.Campaign.__annotations__ + assert "delivery" in types.CampaignCreate.__annotations__ + assert "delivery" in types.CampaignSchedule.__annotations__ diff --git a/packages/python-sdk/usesend/types.py b/packages/python-sdk/usesend/types.py index d0a697b1..9e54350a 100644 --- a/packages/python-sdk/usesend/types.py +++ b/packages/python-sdk/usesend/types.py @@ -417,6 +417,23 @@ class ContactDeleteResponse(TypedDict): # Campaigns # --------------------------------------------------------------------------- +CampaignDeliveryMode = Literal["ALL_AT_ONCE", "GRADUAL"] +CampaignDeliveryInterval = Literal["minute", "hour"] + + +class CampaignDeliveryAllAtOnce(TypedDict): + strategy: Literal["all_at_once"] + + +class CampaignDeliveryGradual(TypedDict): + strategy: Literal["gradual"] + batchPercentage: int + interval: CampaignDeliveryInterval + + +CampaignDelivery = Union[CampaignDeliveryAllAtOnce, CampaignDeliveryGradual] + + Campaign = TypedDict( "Campaign", { @@ -432,6 +449,13 @@ class ContactDeleteResponse(TypedDict): "scheduledAt": Optional[str], "batchSize": int, "batchWindowMinutes": int, + "deliveryMode": CampaignDeliveryMode, + "deliveryBatchPercentage": Optional[int], + "deliveryIntervalMinutes": Optional[int], + "deliveryBatchSize": Optional[int], + "currentDeliveryBatch": int, + "deliveryBatchProcessed": int, + "nextDeliveryAt": Optional[str], "total": int, "sent": int, "delivered": int, @@ -466,6 +490,7 @@ class ContactDeleteResponse(TypedDict): "sendNow": NotRequired[bool], "scheduledAt": NotRequired[str], "batchSize": NotRequired[int], + "delivery": NotRequired[CampaignDelivery], }, ) @@ -485,6 +510,13 @@ class ContactDeleteResponse(TypedDict): "scheduledAt": Optional[str], "batchSize": int, "batchWindowMinutes": int, + "deliveryMode": CampaignDeliveryMode, + "deliveryBatchPercentage": Optional[int], + "deliveryIntervalMinutes": Optional[int], + "deliveryBatchSize": Optional[int], + "currentDeliveryBatch": int, + "deliveryBatchProcessed": int, + "nextDeliveryAt": Optional[str], "total": int, "sent": int, "delivered": int, @@ -507,6 +539,7 @@ class CampaignSchedule(TypedDict, total=False): scheduledAt: Optional[str] batchSize: Optional[int] sendNow: Optional[bool] + delivery: CampaignDelivery class CampaignScheduleResponse(TypedDict, total=False): diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts index b2a44ab1..568f2b1a 100644 --- a/packages/sdk/types/schema.d.ts +++ b/packages/sdk/types/schema.d.ts @@ -1605,6 +1605,16 @@ export interface paths { /** @description Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am', 'next monday 10:30') */ scheduledAt?: string; batchSize?: number; + delivery?: { + /** @enum {string} */ + strategy: "all_at_once"; + } | { + /** @enum {string} */ + strategy: "gradual"; + batchPercentage: number; + /** @enum {string} */ + interval: "minute" | "hour"; + }; }; }; }; @@ -1629,6 +1639,15 @@ export interface paths { scheduledAt: string | null; batchSize: number; batchWindowMinutes: number; + /** @enum {string} */ + deliveryMode: "ALL_AT_ONCE" | "GRADUAL"; + deliveryBatchPercentage: number | null; + deliveryIntervalMinutes: number | null; + deliveryBatchSize: number | null; + currentDeliveryBatch: number; + deliveryBatchProcessed: number; + /** Format: date-time */ + nextDeliveryAt: string | null; total: number; sent: number; delivered: number; @@ -1694,6 +1713,15 @@ export interface paths { scheduledAt: string | null; batchSize: number; batchWindowMinutes: number; + /** @enum {string} */ + deliveryMode: "ALL_AT_ONCE" | "GRADUAL"; + deliveryBatchPercentage: number | null; + deliveryIntervalMinutes: number | null; + deliveryBatchSize: number | null; + currentDeliveryBatch: number; + deliveryBatchProcessed: number; + /** Format: date-time */ + nextDeliveryAt: string | null; total: number; sent: number; delivered: number; @@ -1748,6 +1776,15 @@ export interface paths { scheduledAt: string | null; batchSize: number; batchWindowMinutes: number; + /** @enum {string} */ + deliveryMode: "ALL_AT_ONCE" | "GRADUAL"; + deliveryBatchPercentage: number | null; + deliveryIntervalMinutes: number | null; + deliveryBatchSize: number | null; + currentDeliveryBatch: number; + deliveryBatchProcessed: number; + /** Format: date-time */ + nextDeliveryAt: string | null; total: number; sent: number; delivered: number; @@ -1798,6 +1835,16 @@ export interface paths { /** @description Timestamp in ISO 8601 format or natural language (e.g., 'tomorrow 9am', 'next monday 10:30') */ scheduledAt?: string; batchSize?: number; + delivery?: { + /** @enum {string} */ + strategy: "all_at_once"; + } | { + /** @enum {string} */ + strategy: "gradual"; + batchPercentage: number; + /** @enum {string} */ + interval: "minute" | "hour"; + }; }; }; };