Valcraven Docs
Features

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

PiecePath
Send engine (producer / consumer)apps/web/lib/campaign-send.ts
Audience resolution (tags)apps/web/lib/audiences.ts
Open/click trackingapps/web/lib/email-tracking.ts
Live progress fan-outapps/web/lib/campaign-realtime.ts
Delivery / bounce pollerapps/web/lib/email-delivery.ts
Admin API routesapps/web/app/api/admin/campaigns/*
Subscribers admin/admin/crm (app/api/admin/subscribers)
Tablesemail_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 tags

lib/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_at is 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_count climbs 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 + pathPurpose
GET /api/admin/campaignsList/search campaigns (paginated)
POST /api/admin/campaignsCreate 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]/sendSend now (draft or scheduled → sending)
POST /api/admin/campaigns/[id]/scheduleSchedule / reschedule (scheduled_at, must be future)
POST /api/admin/campaigns/[id]/cancelCancel a schedule (scheduled → draft)
POST /api/admin/campaigns/[id]/resendRe-dispatch a failed campaign to unsent recipients
POST /api/admin/campaigns/audience-countPreview how many subscribers a filter resolves to
GET /api/admin/campaigns/[id]/analyticsEvent counts (delivered/opened/clicked/bounced)
POST /api/admin/campaigns/sync-deliveryTrigger 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 of CAMPAIGN_BATCH_SIZE (default 50), records batches_total, and sets the campaign to sending.
  • Transport is chosen by binding availability:
    • Production (Cloudflare Workers): the CAMPAIGN_QUEUE binding — a real Cloudflare Queue ([[queues.producers]] / [[queues.consumers]] in wrangler.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.
  • Consumer (processCampaignBatch()) sends one tracked email per recipient, writes a sent event, and atomically bumps sent_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; when batches_done + batches_failed == batches_total the campaign flips to sent (or failed if 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 a clicked event 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 an opened event 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 sent with sent_count = 0 — nothing is enqueued.
  • resend only works on a failed campaign and only re-sends recipients without a sent event, 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.

On this page