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-keyheader only
- Admin (
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:
PUT /campaigns/:idwithscheduled_at→ DO is created, alarm set toscheduled_at- Alarm fires → calls
/internal/fire/:campaignIdon newsletter-service - 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_SERVICEFetcher binding - Comms abstracts the email provider (Resend in production)
- Comms sends bounce/complaint/delivered/opened/clicked events back to newsletter via
/internal/eventand/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. |