Skip to content

Add newsletter template#1064

Open
pfstr wants to merge 7 commits into
cloudflare:mainfrom
pfstr:add-newsletter-template
Open

Add newsletter template#1064
pfstr wants to merge 7 commits into
cloudflare:mainfrom
pfstr:add-newsletter-template

Conversation

@pfstr

@pfstr pfstr commented Jul 20, 2026

Copy link
Copy Markdown

Newsletter template

A serverless newsletter that runs entirely on the user's own Cloudflare account: a hosted (and embeddable) signup form, compliant one-click unsubscribe (RFC 8058), and a simple /admin page to send a campaign. Subscribers live in D1, so the user owns the data.

Sending is deliberately provider-agnostic: src/email.ts is a small sendEmail() adapter the user wires to their own email provider, so the template ships tied to no vendor. Collecting subscribers works out of the box with zero configuration.

Queued sending

Campaigns go through a transactional outbox in D1: POST /api/send queues one row per recipient and returns immediately; a minutely cron drains SEND_BATCH rows per run (default 40 — sized so every invocation stays inside free-plan subrequest limits, whatever the list size). Claims are atomic (no double-sends across overlapping runs), crashed runs are reclaimed after 10 minutes, and each recipient gets up to 3 attempts. Unsubscribing also cancels that address's queued-but-undelivered emails. An optional sendEmailBatch() hook in the adapter uses a provider's batch endpoint (one API call, many emails) for high-throughput delivery.

Compliance built in

  • Every campaign email automatically gets a footer with a working unsubscribe link and the sender's postal address (SENDER_ADDRESS) — the CAN-SPAM basics
  • RFC 8058 List-Unsubscribe / List-Unsubscribe-Post headers are built by the app and handed to the sendEmail() adapter on every send
  • Unsubscribe links open a one-button confirmation page, so link-prefetching mail scanners can't unsubscribe readers by accident; mail clients use the one-click POST directly
  • Opt-outs take effect immediately, scrub the subscriber's personal data (data minimization), and are timestamped — alongside signup and double-opt-in confirmation — as a consent audit trail

Optional, off by default

  • Double opt-in (DOUBLE_OPT_IN) — a confirmation-email step before a subscriber is added
  • Bot protection via Cloudflare Turnstile on the signup form
  • Automatic RSS → email on a cron trigger, with a first-run baseline and per-post dedup so posts are never emailed twice
  • Compliance extrasSENDER_ADDRESS (postal address in every footer) and PRIVACY_URL (privacy-policy link under the signup form)

Products & bindings

  • Workers + D1
  • Handlers: fetch (Hono) and scheduled (cron trigger)
  • Deploy-screen config: ADMIN_TOKEN, FROM_NAME, FROM_EMAIL, DOUBLE_OPT_IN, SEND_BATCH

Notes for reviewers

  • E2E tests in playwright-tests/newsletter-template.spec.ts using the shared fixtures.
  • preview_image_url / preview_icon_url left out for the Growth team to provide; a screenshot is included in the README for now.

pfstr added 7 commits July 20, 2026 19:46
- Append a footer with unsubscribe link and SENDER_ADDRESS (postal
  address) to every campaign email - CAN-SPAM requires a physical address
- Attach ready-made RFC 8058 List-Unsubscribe headers to every send
- Unsubscribe links show a one-button confirmation page (GET) so mail
  scanners can't unsubscribe readers by accident; POST executes,
  including RFC 8058 one-click requests
- Scrub name and extra fields on opt-out (data minimization); record
  confirmed_at / unsubscribed_at as a consent audit trail
- Optional PRIVACY_URL renders a privacy-policy link under the signup
  forms; /admin warns while SENDER_ADDRESS is missing
- README: Staying compliant section; e2e test for the confirmation step
- POST /api/send stores the campaign and queues one outbox row per
  subscriber, returns immediately; a minutely cron drains SEND_BATCH
  rows per run (default 40, sized for the free plan)
- Atomic claims prevent double-sends, crashed runs are reclaimed after
  10 minutes, up to 3 attempts per recipient
- Unsubscribing cancels queued-but-undelivered emails to that address
- Optional sendEmailBatch() hook delivers up to ~1,000 emails per API
  call; RSS auto-send queues through the same outbox
- Migration 0003: outbox table, campaigns.body_html/base_url
…fails

A throwing sendEmailBatch() previously failed the whole drain run and
burned retry attempts without delivering anything. The drain now falls
back to individual sendEmail() calls within the same run and logs
delivery errors instead of swallowing them.
@pfstr
pfstr force-pushed the add-newsletter-template branch from 1f7aa4f to 5255a5d Compare July 23, 2026 12:12
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.

1 participant