Skip to content

feat: add gradual campaign delivery#436

Open
Pankaj3112 wants to merge 6 commits into
usesend:mainfrom
Pankaj3112:feat/gradual-campaign-sending
Open

feat: add gradual campaign delivery#436
Pankaj3112 wants to merge 6 commits into
usesend:mainfrom
Pankaj3112:feat/gradual-campaign-sending

Conversation

@Pankaj3112

@Pankaj3112 Pankaj3112 commented Jul 21, 2026

Copy link
Copy Markdown

Closes #389

Summary

  • add gradual campaign delivery alongside the existing all-at-once mode
  • let users configure a batch percentage and delivery interval from the dashboard and public API
  • snapshot campaign recipients and process them in deterministic, retry-safe delivery waves
  • support pausing and resuming gradual campaigns without releasing a due batch while paused
  • show live processed, pending, suppressed, skipped, and failed recipient progress
  • expose the new scheduling fields through the OpenAPI schema, JavaScript types, and Python SDK

Impact

Campaign senders can throttle large sends into predictable waves instead of queueing the entire audience at once. Existing campaigns retain all-at-once behavior by default.

This implements fixed-percentage interval throttling and the underlying resumable delivery path. Adaptive ramp-up strategies and daily-volume caps are outside this initial scope.

Screenshots

Scheduling Dialog Completed Sumary

Migrations

  • add delivery mode, scheduling, progress, pause, and audience-preparation fields
  • add per-recipient campaign delivery state and timestamps
  • preserve existing campaign behavior with ALL_AT_ONCE and treat existing campaign recipients as already queued
  • add the high-traffic Email(campaignId, latestStatus) index in a separate one-statement migration using CREATE INDEX CONCURRENTLY

Both feature migrations were deployed successfully from an empty PostgreSQL database as part of the integration test run.

Verification

  • pnpm test:web — 181 tests passed
  • pnpm test:web:integration:full — all 43 migrations applied from an empty database and 11 integration tests passed
  • pnpm --filter=web typecheck
  • focused ESLint with zero warnings on all changed web TypeScript files
  • Prettier check on changed source files
  • Python SDK tests — 9 passed
  • pnpm build — all workspace builds passed
  • git diff --check

Manual QA

  • scheduled a four-recipient campaign at 50% per minute
  • verified the first two recipients were processed as one wave
  • paused the campaign and confirmed the due second wave remained held
  • resumed the campaign and confirmed the remaining two recipients were processed
  • verified the completed campaign reported two batches and four processed recipients

Summary by cubic

Adds gradual campaign delivery with fixed-percentage batches on minute/hour intervals, plus pause/resume, scheduling previews, and live progress. Keeps existing campaigns all at once by default and exposes settings in the dashboard and Public API.

  • New Features

    • Choose All at once or Gradual (x% per minute/hour) when scheduling; available in the dashboard and Public API, with OpenAPI, TypeScript SDK types, and Python SDK updated.
    • Deterministic, retry-safe waves with per-recipient statuses (pending, processing, queued, suppressed, skipped, failed); pausing holds due batches until resume.
    • Scheduler enqueues waves on schedule, updates next-delivery, and honors batch windows; campaign page shows progress, and the scheduling dialog previews batch timing and completion.
  • Migration

    • Adds delivery mode and scheduling fields to Campaign; adds status and processedAt to CampaignEmail and makes emailId nullable; existing rows default to ALL_AT_ONCE.
    • Adds concurrent indexes: CampaignEmail(campaignId, status, contactId), Campaign(status, nextDeliveryAt), Email(campaignId, latestStatus). Run Prisma migrations as usual.

Written for commit db0a28b. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added campaign delivery modes: All at once or Gradual batching.
    • Gradual delivery supports batch percentage (1–50) and interval (minutes or hours), including estimated completion and next scheduled delivery time.
    • Added campaign delivery progress/batch status visibility in the campaign details view.
    • API, web UI, and Python SDK now support creating and scheduling campaigns with delivery configuration.
    • Added campaign delivery analytics endpoints for audience size and live delivery progress.
  • Improvements
    • Improved scheduler handling for gradual waves and added delivery-aware pause/resume behavior.

@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

@Pankaj3112 is attempting to deploy a commit to the kmkoushik's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds gradual or all-at-once campaign delivery across the API, database, service layer, SDKs, scheduler, and dashboard. Campaigns store delivery and recipient-processing state, claim recipients in waves, validate claim-bound email processing, and update progress and scheduling timestamps. New analytics procedures expose audience and delivery progress. Scheduling and campaign details interfaces display gradual-delivery settings, estimates, wave progress, and completion state.

Possibly related PRs

  • usesend/useSend#274 — Extends the campaign API endpoints that this change updates with delivery configuration and response fields.
  • usesend/useSend#352 — Introduces the campaign delete response contract extended here with delivery fields.
  • usesend/useSend#415 — Modifies related campaign recipient sending and failure-handling flows.

Suggested reviewers: kmkoushik

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Implements throttled, queue-based campaign sending with progress tracking, covering the issue's core warm-up and monitoring goals.
Out of Scope Changes check ✅ Passed Changes stay aligned with campaign delivery, scheduling, API, schema, UI, migration, and SDK updates; no clear unrelated additions stand out.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding gradual campaign delivery support.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Pankaj3112
Pankaj3112 marked this pull request as ready for review July 21, 2026 17:04

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
apps/web/src/server/public-api/api/campaigns/create-campaign.ts (1)

58-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated delivery-strategy mapping into a shared helper. The lowercase-request → uppercase-service delivery mapping is duplicated byte-for-byte in three call sites; a single helper (e.g. toServiceDelivery(body.delivery)) removes divergence risk as the union evolves.

  • apps/web/src/server/public-api/api/campaigns/create-campaign.ts#L58-L66: replace inline mapping with the shared helper.
  • apps/web/src/server/public-api/api/campaigns/create-campaign.ts#L79-L87: replace the identical inline mapping (schedule-on-create path) with the same helper.
  • apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts#L59-L67: replace the identical inline mapping with the same helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/server/public-api/api/campaigns/create-campaign.ts` around lines
58 - 66, The delivery strategy mapping is duplicated across three campaign API
call sites and should be centralized. Add a shared helper such as
toServiceDelivery(body.delivery) that preserves the current undefined, GRADUAL,
and ALL_AT_ONCE mappings, then replace the inline mappings at
apps/web/src/server/public-api/api/campaigns/create-campaign.ts lines 58-66 and
79-87 and apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts
lines 59-67 with the helper.
apps/web/src/server/jobs/campaign-scheduler-job.ts (1)

43-82: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for the enqueue-failure path once the swallowed-rejection bug is fixed. The scheduler's failure-reporting logic (flagged separately) is currently untested, which is why the regression went unnoticed.

  • apps/web/src/server/jobs/campaign-scheduler-job.ts#L43-L82: fix the .catch()-before-allSettled bug so rejected/fulfilled counts reflect actual outcomes (see proposed diff on this file).
  • apps/web/src/server/jobs/campaign-scheduler-job.unit.test.ts#L49-L106: add a test where mocks.queueBatch rejects for one campaign and assert the resulting rejected/fulfilled counts (or the corresponding logger.error call) reflect that failure.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/server/jobs/campaign-scheduler-job.ts` around lines 43 - 82, In
apps/web/src/server/jobs/campaign-scheduler-job.ts lines 43-82, update the
enqueuePromises flow so CampaignBatchService.queueBatch rejections remain
rejected until Promise.allSettled, while preserving failure logging without
swallowing the rejection; ensure rejected and fulfilled counts reflect actual
outcomes. In apps/web/src/server/jobs/campaign-scheduler-job.unit.test.ts lines
49-106, add coverage for one mocks.queueBatch rejection and assert the resulting
rejected/fulfilled summary or corresponding logger.error call records the
failure.
apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql (1)

28-32: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider building the CampaignEmail/Campaign indexes concurrently.

CREATE INDEX (non-concurrent) takes a write lock for the duration of the build. CampaignEmail holds one row per recipient per campaign and can be large, so this can stall inserts/updates during deploy. Prisma wraps a migration in a transaction (blocking CONCURRENTLY), but you already have the "single-statement migration outside a transaction" pattern in 20260721190000_add_campaign_email_status_index; splitting these index creations into their own migrations lets you use CREATE INDEX CONCURRENTLY and avoid the write stall.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql`
around lines 28 - 32, Split the CampaignEmail and Campaign index creation from
this migration into separate single-statement migrations, following the
established pattern in 20260721190000_add_campaign_email_status_index. Define
both indexes with CREATE INDEX CONCURRENTLY so each build runs outside Prisma’s
transaction and avoids blocking writes.

Source: Linters/SAST tools

apps/web/src/server/service/campaign-service.ts (1)

2020-2039: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse getGradualDeliveryWaveSize instead of re-deriving wave size here.

This block recomputes previouslyReleased/currentWaveSize with the exact formula in getGradualDeliveryWaveSize (Line 1568-1574). Two copies of the wave-sizing math can silently diverge if the batching rule ever changes. The select already returns the needed fields, so you can construct the minimal shape and delegate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/server/service/campaign-service.ts` around lines 2020 - 2039,
Replace the duplicated previouslyReleased/currentWaveSize calculation in the
current-wave completion check with a call to getGradualDeliveryWaveSize, passing
the minimal campaign fields already available from the select. Preserve the
existing currentWaveComplete and nextDeliveryAt early-return behavior while
centralizing wave-sizing logic in getGradualDeliveryWaveSize.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/server/jobs/campaign-scheduler-job.ts`:
- Around line 43-82: Update the enqueue flow around
CampaignBatchService.queueBatch and the enqueuePromises summary so individual
enqueue failures remain rejected for Promise.allSettled to observe. Preserve
logging of each failure with campaignId, but rethrow or otherwise propagate the
caught error after logging, allowing the existing rejected count in “Scheduler
enqueue summary” to reflect actual failures.

In `@apps/web/src/server/public-api/schemas/campaign-schema.unit.test.ts`:
- Around line 40-45: Extend the campaignScheduleSchema tests around the “accepts
the all-at-once delivery strategy” case to cover all_at_once with
batchPercentage and interval, and update the all_at_once schema branch to reject
those gradual-only fields rather than stripping them. Use strict object
validation or an equivalent refinement while preserving acceptance of valid
all_at_once payloads.

---

Nitpick comments:
In
`@apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql`:
- Around line 28-32: Split the CampaignEmail and Campaign index creation from
this migration into separate single-statement migrations, following the
established pattern in 20260721190000_add_campaign_email_status_index. Define
both indexes with CREATE INDEX CONCURRENTLY so each build runs outside Prisma’s
transaction and avoids blocking writes.

In `@apps/web/src/server/jobs/campaign-scheduler-job.ts`:
- Around line 43-82: In apps/web/src/server/jobs/campaign-scheduler-job.ts lines
43-82, update the enqueuePromises flow so CampaignBatchService.queueBatch
rejections remain rejected until Promise.allSettled, while preserving failure
logging without swallowing the rejection; ensure rejected and fulfilled counts
reflect actual outcomes. In
apps/web/src/server/jobs/campaign-scheduler-job.unit.test.ts lines 49-106, add
coverage for one mocks.queueBatch rejection and assert the resulting
rejected/fulfilled summary or corresponding logger.error call records the
failure.

In `@apps/web/src/server/public-api/api/campaigns/create-campaign.ts`:
- Around line 58-66: The delivery strategy mapping is duplicated across three
campaign API call sites and should be centralized. Add a shared helper such as
toServiceDelivery(body.delivery) that preserves the current undefined, GRADUAL,
and ALL_AT_ONCE mappings, then replace the inline mappings at
apps/web/src/server/public-api/api/campaigns/create-campaign.ts lines 58-66 and
79-87 and apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts
lines 59-67 with the helper.

In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 2020-2039: Replace the duplicated
previouslyReleased/currentWaveSize calculation in the current-wave completion
check with a call to getGradualDeliveryWaveSize, passing the minimal campaign
fields already available from the select. Preserve the existing
currentWaveComplete and nextDeliveryAt early-return behavior while centralizing
wave-sizing logic in getGradualDeliveryWaveSize.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f4f1c24e-feaa-4e02-a067-610605050e91

📥 Commits

Reviewing files that changed from the base of the PR and between b1b1f86 and 5dc089a.

📒 Files selected for processing (23)
  • apps/docs/api-reference/openapi.json
  • apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql
  • apps/web/prisma/migrations/20260721190000_add_campaign_email_status_index/migration.sql
  • apps/web/prisma/schema.prisma
  • apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx
  • apps/web/src/app/(dashboard)/campaigns/schedule-campaign.tsx
  • apps/web/src/app/(dashboard)/campaigns/toggle-pause-campaign.tsx
  • apps/web/src/lib/campaign-delivery.ts
  • apps/web/src/lib/campaign-delivery.unit.test.ts
  • apps/web/src/server/api/routers/campaign-delivery.trpc.test.ts
  • apps/web/src/server/api/routers/campaign.ts
  • apps/web/src/server/jobs/campaign-scheduler-job.ts
  • apps/web/src/server/jobs/campaign-scheduler-job.unit.test.ts
  • apps/web/src/server/public-api/api/campaigns/create-campaign.ts
  • apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts
  • apps/web/src/server/public-api/schemas/campaign-schema.ts
  • apps/web/src/server/public-api/schemas/campaign-schema.unit.test.ts
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/service/campaign-service.unit.test.ts
  • packages/python-sdk/README.md
  • packages/python-sdk/tests/test_resources.py
  • packages/python-sdk/usesend/types.py
  • packages/sdk/types/schema.d.ts

Comment thread apps/web/src/server/jobs/campaign-scheduler-job.ts Outdated
Comment on lines +40 to +45
it("accepts the all-at-once delivery strategy", () => {
const result = campaignScheduleSchema.safeParse({
delivery: { strategy: "all_at_once" },
});

expect(result.success).toBe(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the schema and its tests
git ls-files 'apps/web/src/server/public-api/**' | sed -n '1,200p'

# Show the relevant test file with line numbers
wc -l apps/web/src/server/public-api/schemas/campaign-schema.unit.test.ts
cat -n apps/web/src/server/public-api/schemas/campaign-schema.unit.test.ts | sed -n '1,220p'

# Find the schema implementation and any related delivery strategy definitions
rg -n "campaignScheduleSchema|all_at_once|batchPercentage|interval|delivery" apps/web/src/server/public-api -g '!**/*.map'

# If the schema is in a nearby file, show it with line numbers
for f in $(rg -l "campaignScheduleSchema|all_at_once|batchPercentage|interval|delivery" apps/web/src/server/public-api -g '!**/*.map' | head -n 5); do
  echo "===== $f ====="
  wc -l "$f"
  cat -n "$f" | sed -n '1,260p'
done

Repository: usesend/useSend

Length of output: 21265


Reject gradual-only fields for all_at_once. Add a case with strategy: "all_at_once" plus batchPercentage/interval; the all_at_once branch is a non-strict z.object, so those keys are stripped and the payload still parses. If that input should be invalid, make the branch strict or add a refinement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/server/public-api/schemas/campaign-schema.unit.test.ts` around
lines 40 - 45, Extend the campaignScheduleSchema tests around the “accepts the
all-at-once delivery strategy” case to cover all_at_once with batchPercentage
and interval, and update the all_at_once schema branch to reject those
gradual-only fields rather than stripping them. Use strict object validation or
an equivalent refinement while preserving acceptance of valid all_at_once
payloads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add warm-up / throttled sending for large email campaigns

1 participant