A complete serverless newsletter on your own Cloudflare account: a signup form, one-click unsubscribe, and a simple page to send an email campaign to your subscribers. You own the data (Cloudflare D1) and bring your own email sender — connect any provider you like.
No servers, no monthly SaaS bill, no command line: it runs on Cloudflare Workers + D1. Signups are free at any scale, and sending is queued in the background — even the free plan works through lists of thousands (see Notes & limits below).
- Signup — a hosted form at
/, an embeddable version for your own site, and aPOST /api/subscribeendpoint - One-click unsubscribe — RFC 8058 headers on every send, with a per-subscriber token
- Compliance built in — every email gets a footer with an unsubscribe link and your postal address; consent and opt-out timestamps are stored (CAN-SPAM / GDPR)
- Send — a
/adminpage: paste a subject + HTML, send a test to yourself, then queue it for everyone; a background job delivers at a steady pace with retries - Your data — subscribers live in a D1 database on your account, exportable any time
- Double opt-in (optional) — a confirmation-email step before a subscriber is added
- Bot protection (optional) — Cloudflare Turnstile on the signup form
- Automatic RSS sending (optional) — email new blog posts to your list on a schedule
Click Deploy to Cloudflare above. On the single setup screen you can fill in:
ADMIN_TOKEN— a password that protects your send page (make up a long random string)FROM_NAME/FROM_EMAIL— the name and address your emails come from
Cloudflare then creates the D1 database, applies the schema, deploys the Worker,
and puts a copy of this repo on your account (with CI — every push redeploys).
No terminal, no wrangler commands.
That's it — your signup page is immediately live at
https://<your-worker>.workers.dev and starts collecting subscribers right away.
All optional. Add them any time in the dashboard under your Worker → Settings → Variables and Secrets (double opt-in can also be set on the deploy screen):
- Double opt-in — set
DOUBLE_OPT_INto"true"to require new subscribers to click a confirmation link before they're added (recommended for CH/EU; requires email to be configured, see below). Default is single opt-in. - Bot protection (Turnstile) —
create a Turnstile widget in your Cloudflare dashboard, then set the public
TURNSTILE_SITE_KEY(variable) andTURNSTILE_SECRET_KEY(secret). The signup form then shows the widget and rejects unverified submissions. Leave both blank to disable. - Postal address (
SENDER_ADDRESS) — e.g.Acme Inc., 123 Main St, Springfield, USA. Appears in the footer of every email; the US CAN-SPAM Act requires a valid physical address in commercial email. Set it before your first campaign. - Privacy policy (
PRIVACY_URL) — absolute URL of your privacy policy; when set, a link appears under the signup form (expected by EU privacy rules). - Throughput (
SEND_BATCH) — queued emails delivered per minutely background run. The default40fits the free plan; raise it on the paid plan or whensendEmailBatchis implemented (see Sending email below). - Localized email texts — the automatic footer and the double-opt-in email
default to English. Set
FOOTER_TEXTandUNSUBSCRIBE_LABELfor the footer, andCONFIRM_SUBJECT/CONFIRM_HTML(supports a{{confirm_url}}merge tag) for the confirmation email, to use your own language. - New-subscriber notification (
NOTIFY_EMAIL) — set it to your own address to receive a short email whenever a subscription becomes active (a single opt-in signup, or a confirmed double opt-in). Requires email to be configured. Leave blank to disable.
Everything runs inside a single Cloudflare Worker. The sequence below traces the main data flows between the participants — laid out left to right: the visitor, you as admin, the Worker, your D1 database and your email provider.
sequenceDiagram
participant V as Visitor
participant A as Admin
participant W as Cloudflare Worker
participant DB as D1 Database
participant EM as Email Provider
Note over V,W: Sign up
V->>W: Subscribe (email + name)
W->>W: Verify Turnstile (if enabled)
W->>DB: Store subscriber
W-->>V: You're in!
opt Double opt-in
W->>EM: Confirmation email
V->>W: Click confirm link
W->>DB: Mark subscribed
end
Note over A,EM: Send a campaign
A->>W: Compose and send (/admin)
W->>DB: Store campaign, queue recipients
W-->>A: Queued!
Note over W,EM: Auto-send from RSS (every 15 min)
W->>DB: Check already-sent posts
W->>DB: Queue new posts only
Note over W,EM: Background delivery (cron, every minute)
W->>DB: Claim next batch from queue
W->>EM: Deliver batch
W->>DB: Mark sent, retry failures
Note over V,EM: Unsubscribe
EM->>V: Email with unsubscribe link
V->>W: Click unsubscribe
W->>DB: Mark unsubscribed
Turnstile and double opt-in are optional; RSS auto-send runs only when you enable it.
You don't need to touch any code. Pick whichever fits you:
1. Just share the link. Your hosted signup page already works — put it in your bio, a post, or an email:
https://<your-worker>.workers.dev
2. Embed it on your site (recommended). Paste this one line into any site builder that allows an "embed" or "custom HTML" block (Webflow, WordPress, Squarespace, Framer, Notion, …). Nothing else to configure:
<iframe src="https://<your-worker>.workers.dev/embed"
style="width:100%;max-width:420px;height:90px;border:0"></iframe>3. Inline form (matches your own styling). If you'd rather the form be part of your page, drop in this snippet — it posts straight to your Worker:
<form onsubmit="event.preventDefault();
fetch('https://<your-worker>.workers.dev/api/subscribe', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ email: this.email.value })
}).then(()=>this.reset());">
<input name="email" type="email" placeholder="you@example.com" required />
<button>Subscribe</button>
</form>The form asks for an email and an optional name by default. To collect more
(company, country, …), add entries to src/fields.ts — each one
automatically appears on the form and is stored as JSON in the data column. No
other file needs changing. In your emails you can personalize with the
{{name}} merge tag.
Already have a list? Import it with
npx wrangler d1 execute newsletter-template-db --remote --command "...".
Collecting subscribers works out of the box. Sending is the one part you wire up yourself — so you keep full control over your email provider, sending domain, and deliverability, and this template stays tied to no one.
Open src/email.ts and implement sendEmail() with your
provider's API. Most transactional email services expose a simple HTTP API you
can call straight from a Worker — there's a commented example in that file to
adapt. Then:
- Add your provider's secret (e.g. an API key) to the Worker: dashboard →
your Worker → Settings → Variables and Secrets, and flip
isEmailConfigured(). - Verify your sending domain with your provider (SPF/DKIM DNS records) — this is what makes your email land in inboxes.
Then open https://<your-worker>.workers.dev/admin, paste your ADMIN_TOKEN,
write your email, send a test to yourself, and send to your list. A compliance
footer — unsubscribe link plus your SENDER_ADDRESS — is appended to every
email automatically; use {{unsubscribe_url}} in your HTML only if you want an
extra inline link.
Campaign sends are queued: /api/send stores the campaign and returns
immediately, then a background job (every minute) delivers SEND_BATCH emails
per run — 40 by default, sized for the free plan — with up to 3 attempts per
recipient. Anyone who unsubscribes while queued is skipped. If your provider
has a batch endpoint (one API call, many emails), implement the optional
sendEmailBatch() in src/email.ts (commented example in the
file) and raise SEND_BATCH — then even the free plan delivers thousands in a
few minutes.
Instead of composing each issue by hand, the Worker can watch your blog's feed and email subscribers automatically whenever you publish. It's off by default. To turn it on, add these in the dashboard under Settings → Variables and Secrets:
RSS_AUTOSEND→"true"RSS_FEED_URL→ your RSS or Atom feed (e.g.https://example.com/rss.xml)PUBLIC_URL→ your Worker's URL (e.g.https://your-worker.workers.dev), so unsubscribe links in the sent emails are absolute- and your email provider configured (see above)
The feed is checked every 15 minutes; new posts are queued and delivered by the same background job as regular campaigns, oldest first. Two safeguards are built in: each post is emailed only once, and the first run just records your current feed as a baseline — it never blasts your back catalogue. Only posts published after you enable it go out.
Anti-spam and privacy laws — the US CAN-SPAM Act, the EU's GDPR and ePrivacy rules, and similar laws elsewhere — put duties on anyone who sends a newsletter. The template takes care of the mechanical part:
- Unsubscribe in every email — a footer with a working unsubscribe link is
appended to every campaign automatically, and every send carries the
RFC 8058
List-Unsubscribeheaders (the native "Unsubscribe" button in Gmail/Outlook, required by Gmail and Yahoo for bulk senders). - Postal address — set
SENDER_ADDRESSand it appears in every footer; CAN-SPAM requires a valid physical address in commercial email. The/adminpage warns you while it's missing. - Opt-outs take effect immediately — no delay (CAN-SPAM allows up to 10 business days), no login, and the link never expires. Queued-but-undelivered emails to that address are cancelled too. The link shows a one-button confirmation page so corporate mail scanners that prefetch links can't unsubscribe your readers by accident; mail clients use the one-click POST directly.
- Data minimization — on unsubscribe, the subscriber's name and extra fields are deleted on the spot; only the address itself is kept as the opt-out record so it can be honored.
- Consent on record — signup, confirmation (double opt-in) and opt-out timestamps are stored per subscriber, so you can prove consent later.
- EU-grade consent — flip
DOUBLE_OPT_INto"true"and setPRIVACY_URLso the signup form links your privacy policy.
Still yours to do: truthful From/subject lines, sending only to people who
actually signed up, domain authentication at your provider (SPF/DKIM/DMARC),
and honoring requests that arrive outside the unsubscribe link — a full GDPR
erasure is one DELETE on your D1 database. None of this is legal advice.
npm install
cp .dev.vars.example .dev.vars # fill in your values
npm run dev # applies the schema to a local D1, then starts Wrangler
Your deployment is a copy of this repo in your own GitHub account — updates here don't reach it automatically (nothing ever pushes into your account). To pull in the latest version:
git remote add template https://github.com/pfstr/newsletter-template
git fetch template
git merge -X theirs --allow-unrelated-histories --no-commit template/main
git checkout HEAD -- src/email.ts src/fields.ts wrangler.json
git commit -m "Update template" && git push-X theirs takes the upstream side of every change; the git checkout line
then keeps your own email adapter, your extra fields, and your wrangler.json
(your D1 database_id lives there). If you've customized other files too,
drop -X theirs and resolve the conflicts by hand. Your CI redeploys on push,
and database migrations are append-only + applied by the predeploy script —
schema upgrades happen by themselves.
The interfaces you build on — sendEmail() / isEmailConfigured() in
src/email.ts and EXTRA_FIELDS in
src/fields.ts — are stable API: breaking changes only in a
new major version, with an upgrade guide. See CHANGELOG.md
for what's new, or watch
releases to get
notified.
- Single opt-in by default — simplest to start. Flip
DOUBLE_OPT_INto"true"for a confirmation-email step; it needs your email provider wired up so the confirmation link can be sent. - Sending is queued — a background run delivers
SEND_BATCHemails per minute (default 40, sized for the free plan's ~50 outbound calls per invocation): a 1,000-recipient campaign takes ~25 minutes on the free plan. On the $5/month paid plan (10,000 calls per invocation) raiseSEND_BATCHinto the hundreds — or implementsendEmailBatch()insrc/email.ts(one API call, many emails) and even the free plan delivers thousands in a few minutes. - Deliverability is your domain's — verify your sending domain with your email provider (SPF/DKIM/DMARC). The one-click deploy provisions the backend; it can't verify your domain for you.
MIT — © Rafael Pfister, rafaelpfister.ch. Free to use, modify and sell; the copyright notice (name + link) must be kept in copies.
