Email campaigns
Admin email marketing on top of Cloudflare Email Service — tag-based audiences, a draft/scheduled/sending lifecycle, queued batched sends, and self-hosted open/click tracking.
Email campaigns
Valcraven ships a full email-marketing subsystem for sending newsletters and announcements to your newsletter subscribers. Admins compose a campaign, target it at an audience, send it now or schedule it, and watch delivery/open/click analytics — all on top of Cloudflare Email Service, with the engagement layer (opens, clicks, delivery status) self-hosted because Cloudflare doesn't provide it.
Campaigns are managed at /admin/campaigns (create at /admin/campaigns/new, detail at /admin/campaigns/[id]). The send engine is lib/campaign-send.ts; audiences are lib/audiences.ts; tracking is lib/email-tracking.ts; live progress is lib/campaign-realtime.ts.
Where it lives
| Piece | Path |
|---|---|
| Send engine (producer / consumer) | apps/web/lib/campaign-send.ts |
| Audience resolution (tags) | apps/web/lib/audiences.ts |
| Open/click tracking | apps/web/lib/email-tracking.ts |
| Live progress fan-out | apps/web/lib/campaign-realtime.ts |
| Delivery / bounce poller | apps/web/lib/email-delivery.ts |
| Admin API routes | apps/web/app/api/admin/campaigns/* |
| Subscribers admin | /admin/crm (app/api/admin/subscribers) |
| Tables | email_campaigns, email_events, newsletter_subscribers |
Subscribers and audiences
Recipients live in the newsletter_subscribers table. People join via the newsletter form (POST /api/subscribe) or an admin adds them at /admin/crm. Every subscriber has a status (active / unsubscribed) and a tags JSON array. See Waitlist & subscribers for the subscribe/unsubscribe flow.
Audiences are modelled as tags ("basic Mailchimp parity") — a named list is just a tag, and a segment is a set of tags. A campaign stores its target in email_campaigns.audience_filter as JSON:
{ "kind": "all" } // every active subscriber (default)
{ "kind": "tags", "tags": ["vip","beta"], "match": "any" } // active subs with ANY of these tags
{ "kind": "tags", "tags": ["vip","beta"], "match": "all" } // active subs with ALL of these tagslib/audiences.ts is the single source of truth. parseAudienceFilter() is tolerant — empty/legacy/malformed values fall back to "all". resolveAudience() returns the recipient emails and is always scoped to status = 'active', so unsubscribed and bounced recipients are never included regardless of tags. countAudience() powers the "will send to N subscribers" preview (POST /api/admin/campaigns/audience-count). Manage a subscriber's tags with PATCH /api/admin/subscribers at /admin/crm.
Campaign lifecycle
A campaign moves through a small state machine (email_campaigns.status):
draft ──schedule──▶ scheduled ──(due)──▶ sending ──▶ sent
│ │ │
└────────send now─────┴───────────────────┘ └─▶ failed ──resend──▶ sending- draft — editable (name, subject, HTML, preview text, audience). Only drafts can be edited or deleted.
- scheduled — a future
scheduled_atis set; the dispatch cron will send it. Can be cancelled back to draft, or superseded by "Send now". - sending — the audience is resolved and batches are enqueued;
sent_countclimbs as batches complete. - sent — every batch completed successfully.
- failed — one or more batches permanently failed. An admin can Resend to the recipients who never got it.
Sending and scheduling use atomic compare-and-swap on status, so two "Send now" clicks — or a click racing the scheduled-dispatch cron — can't double-send: only one claim wins and the loser gets a 409.
Admin API
All routes require the ADMIN_CAMPAIGNS permission (subscribers use ADMIN_CRM).
| Method + path | Purpose |
|---|---|
GET /api/admin/campaigns | List/search campaigns (paginated) |
POST /api/admin/campaigns | Create a draft (name, subject, html_content, preview_text?) |
GET /api/admin/campaigns/[id] | Fetch one campaign |
PATCH /api/admin/campaigns/[id] | Edit a draft (incl. audience_filter) |
DELETE /api/admin/campaigns/[id] | Delete a draft |
POST /api/admin/campaigns/[id]/send | Send now (draft or scheduled → sending) |
POST /api/admin/campaigns/[id]/schedule | Schedule / reschedule (scheduled_at, must be future) |
POST /api/admin/campaigns/[id]/cancel | Cancel a schedule (scheduled → draft) |
POST /api/admin/campaigns/[id]/resend | Re-dispatch a failed campaign to unsent recipients |
POST /api/admin/campaigns/audience-count | Preview how many subscribers a filter resolves to |
GET /api/admin/campaigns/[id]/analytics | Event counts (delivered/opened/clicked/bounced) |
POST /api/admin/campaigns/sync-delivery | Trigger the delivery poller on demand |
Queued, batched sending
The send route does not loop through every recipient inline (that dies on Workers CPU/wall-clock limits for large lists). Instead it resolves the audience and enqueues batches, returning immediately. lib/campaign-send.ts owns the producer/consumer:
- Producer (
enqueueCampaignSend()) splits recipients into batches ofCAMPAIGN_BATCH_SIZE(default 50), recordsbatches_total, and sets the campaign tosending. - Transport is chosen by binding availability:
- Production (Cloudflare Workers): the
CAMPAIGN_QUEUEbinding — a real Cloudflare Queue ([[queues.producers]]/[[queues.consumers]]inwrangler.toml). The consumer on the same Worker drains batches with CF Queue retry/backoff. - Dev / non-Workers: the SQLite/D1 job queue (
lib/jobs.ts), drained in-process right after enqueue so an admin send completes in seconds rather than waiting for a cron tick.
- Production (Cloudflare Workers): the
- Consumer (
processCampaignBatch()) sends one tracked email per recipient, writes asentevent, and atomically bumpssent_count. It is idempotent per (campaign, recipient) — a retried batch never double-sends. On a provider rate-limit / daily-cap it throws so the whole batch is requeued with backoff; already-sent recipients are skipped on replay. - Finalize — each completed batch bumps
batches_done; whenbatches_done + batches_failed == batches_totalthe campaign flips tosent(orfailedif any batch failed). This is order-independent and guarded so concurrent batches finalize exactly once.
One-time production setup: wrangler queues create valcraven-campaign-send (rename per project). The consumer authenticates its internal POST with CRON_SECRET, so that secret must be set.
Scheduled sends
POST /api/admin/campaigns/[id]/schedule puts a draft into scheduled with a future ISO scheduled_at; /cancel returns it to draft. The recurring dispatch-scheduled-campaigns job scans for due campaigns, atomically claims each (scheduled → sending, so overlapping ticks can't double-dispatch), resolves its audience, and enqueues via the producer above. On Workers it runs on the */15 * * * * Cron Trigger; the dev cron runs it every minute. A reconcile-stuck-campaigns job on the same schedule rescues any campaign stuck in sending.
Open and click tracking (self-hosted)
Cloudflare Email Service has no open/click tracking, so Valcraven rewrites campaign HTML per recipient in applyCampaignTracking() (lib/email-tracking.ts):
- Click tracking — every
<a href="http(s)://…">is rewritten to/api/email/click?t=<token>. The destination URL is signed into the token, so the endpoint logs aclickedevent and 302-redirects to the signed target. Because the target is part of the HMAC signature (not a separate, swappable query param), the endpoint can't be turned into an open redirect — a forged or non-http target sends the visitor to the app home instead. - Open tracking — a 1×1 pixel pointing at
/api/email/open?t=<token>is injected before</body>. The endpoint logs anopenedevent and returns a transparent GIF.
Both carry an HMAC-signed token (signTrackingToken) encoding the campaign id + recipient email, signed with EMAIL_TRACKING_SECRET (falls back to BETTER_AUTH_SECRET). The signature stops third parties forging events for other subscribers; the payload is signed, not encrypted. Transactional emails are not link-rewritten — tracking applies only to campaigns.
Unsubscribe links (/api/unsubscribe) are deliberately left untouched by the rewriter so one-click unsubscribe keeps working.
Opens are noisy. Apple Mail Privacy Protection and Gmail's image proxy pre-fetch or proxy pixels, which inflates or suppresses opens. Treat opens as directional; clicks are the reliable signal.
Analytics and delivery status
All engagement lands in email_events (event types: sent, delivered, opened, clicked, bounced, rejected, failed) and surfaces on the campaign detail page. GET /api/admin/campaigns/[id]/analytics aggregates counts by type.
Delivery and bounce status come from the poller (lib/email-delivery.ts) described in Transactional email — an hourly job that reads Cloudflare's GraphQL Analytics API, upserts delivered / bounced / rejected rows, and auto-unsubscribes hard bounces. Admins can also trigger it via POST /api/admin/campaigns/sync-delivery.
Live progress
While a campaign sends, the detail page updates without a refresh. publishCampaignState() (lib/campaign-realtime.ts) publishes progress to the admin-only realtime channel campaign:<id> once per completed batch (not per recipient, so a large send can't flood the channel), and publishCampaignOpened() nudges the client when opens tick up (coalesced to absorb pixel-prefetch bursts). This is a best-effort UX layer — the database row is always the source of truth, so a broken realtime hop never affects send correctness. See Realtime.
Gotchas
- Empty audience finalizes straight to
sentwithsent_count = 0— nothing is enqueued. resendonly works on afailedcampaign and only re-sends recipients without asentevent, so it's safe to click after a partial failure.- The delivery/open/click tiles stay at zero until you configure the tracking secret and (for delivery) the analytics token + zone id.
- Drip automation / event-triggered sequences are intentionally out of scope — campaigns are one-off sends.
Related
- Transactional email — the underlying
sendEmail()transport and delivery poller - Waitlist & subscribers — how subscribers join and unsubscribe
- Realtime — the channel that powers live progress
- Admin dev wiki: Email & Campaign Tracking
Transactional email
Send welcome, verification, password-reset and other transactional email through Cloudflare Email Service with a single sendEmail() transport.
Developer API Keys
Optional per-user API keys for programmatic access — off by default, SHA-256 at rest, reveal-once, and how to enable and opt a route in.