logicspike/docs

Newsletter

Newsletter Service — Architecture

Last Updated: 2026-06-28 Status: Active

How the newsletter system fits together. Read this before touching anything.


1. System Overview


2. Service Inventory

2.1 newsletter-service (apps/newsletter-service)

  • Port: 8795 (local) · Cloudflare Workers (production)
  • Framework: Hono v4
  • Three surfaces:
    • Admin (/subscribers, /campaigns, /templates, /segments, /sections, /blog-binding) — JWT via gateway, permission-gated
    • Public (/public/subscribe, /public/confirm, /public/unsubscribe) — rate-limited, HMAC-token-based
    • Internal (/internal/bounce, /internal/event, /internal/blog/*) — x-internal-key header only

Middleware pipeline (admin routes):

Step Middleware Purpose
1 dbMiddleware Creates Neon connection, caches per request
2 Gateway guard Validates x-gateway-key against GATEWAY_SECRET
3 Context hydration Reads x-tenant-id, x-user-id, x-user-permissions
4 requireTenant Rejects 400 if x-tenant-id missing
5 requirePermission(...) Route-specific permission check

2.2 CampaignScheduler Durable Object (src/scheduler.ts)

One DO instance per scheduled campaign. Alarm fires at scheduled_at time and calls fireCampaign(). This replaces cron polling and costs zero compute while idle.

DO lifecycle:

  1. PUT /campaigns/:id with scheduled_at → DO is created, alarm set to scheduled_at
  2. Alarm fires → calls /internal/fire/:campaignId on newsletter-service
  3. Campaign fires → DO discards itself

Cancel: DO alarm is deleted via PUT /campaigns/:id with scheduled_at: null or POST /campaigns/:id/cancel.

2.3 Communication Service (apps/communication)

Not part of newsletter-service but tightly coupled:

  • Newsletter → Comms via COMMS_SERVICE Fetcher binding
  • Comms abstracts the email provider (Resend in production)
  • Comms sends bounce/complaint/delivered/opened/clicked events back to newsletter via /internal/event and /internal/bounce

3. Request Flows

3.1 Campaign Send — Manual Fire

3.2 Double Opt-In — Form Subscribe

3.3 Blog Publish → Newsletter


4. Queue Pipeline Detail

fireCampaign()

  ├─ Validate campaign (status must be draft|scheduled)
  ├─ CAS status → sending (idempotent)
  ├─ Query recipients: confirmed + active + not in suppression list
  ├─ Charge credits: Math.ceil(count / 5) via Core DB
  ├─ Per recipient:
  │   ├─ Build HMAC unsubscribe URL (UNSUBSCRIBE_SECRET)
  │   └─ renderCampaignFields(): substitute {{ tokens }} in subject/html/text
  └─ Enqueue in batches of 100 → CF Queue (newsletter-sends)
 
Queue Consumer (max_batch_size=50, max_retries=3)

  ├─ For each job:
  │   ├─ POST COMMS_SERVICE /internal/send { channel, to, subject, html, text, idempotencyKey }
  │   ├─ On success: INSERT nl_campaign_sends (status=sent)
  │   └─ On error: classify code → INSERT (status=failed) → message.retry()

  └─ After each batch: UPDATE nl_campaigns (total_sent, total_failed)
       └─ Drain check: if (total_sent + total_failed >= total_recipients) → status=sent
 
DLQ Consumer (max_batch_size=50, max_retries=1)
  └─ Status → dead_letter (so drain check can complete)

5. Header Protocol

Header Set by Read by Content
x-gateway-key Gateway newsletter-service admin guard Shared secret proving gateway ran first
x-tenant-id Gateway (admin/public) newsletter-service all routes Tenant isolation key
x-user-id Gateway (admin) newsletter-service context User identity
x-user-permissions Gateway (admin) newsletter-service permission guards JSON-encoded string[]
x-user-role Gateway (admin) newsletter-service context Role string
x-internal-key newsletter-service or blog-service newsletter-service /internal/* guard Worker-to-worker shared secret

6. Rate Limits

Surface Limit Binding
POST /public/subscribe 10 req / 60s per (tenant, IP) NL_SUBSCRIBE_LIMITER (native)
POST /internal/bounce 30 req / 60s per email NL_BOUNCE_LIMITER (native)

Rate limiters use native Cloudflare Workers rate limiting (no KV quota consumed). KV namespace (RATE_LIMIT_KV) is wired as legacy fallback.


7. Key Design Decisions

Decision Rationale
Queue-based per-recipient sends Linear scalability; no single Worker execution timeout for large lists. 50k recipients = 50k queue messages, processed across many invocations.
Upfront credit charge Credits debited before enqueue prevents orphan sends if Worker crashes mid-loop. No partial-charge scenarios.
Global suppression list Hard bounces and complaints damage ALL tenants' sender reputation. Cross-tenant suppression is non-negotiable for deliverability.
Durable Object scheduling One DO alarm per campaign vs cron polling every minute. Zero compute while campaign is idle.
HMAC tokens for confirm/unsubscribe Stateless — no DB lookup needed to verify validity. Signed with UNSUBSCRIBE_SECRET; tamper-evident.
Template snapshot at campaign create Campaign body is frozen at create time. Editing a template never silently changes a scheduled send.
Blog integration is fire-and-forget Blog publish cannot fail due to newsletter issues. 204 = no binding = silent success. Errors logged, not propagated.
Provider-agnostic via Comms Swapping email providers (Resend → Postmark, etc.) requires only comms-service changes. Newsletter never calls Resend directly.
Newsletter