Back to all posts
Tutorial

Building a $0 Multi-Language Newsletter System: Cloudflare Workers, D1, Resend & HMAC Auth

Every indie maker and blogger faces the same problem: how do you let visitors subscribe to your work without paying $20/month for a newsletter service and introducing another vendor lock-in?

When I decided to add a newsletter to my portfolio, I had three non-negotiable requirements:

  1. Zero cost — everything on free tiers or self-hosted
  2. Full control — no third-party data silos; subscriber data lives in my own database
  3. Production-grade security — HMAC-signed unsubscribe links so visitors can't unsubscribe each other
  4. Multi-language support — localized welcome emails and unsubscribe pages in 7 languages

I built this entirely on the Cloudflare platform and want to show you exactly how.


The Architecture: Free Tier, No Compromises

The complete stack:

  • Cloudflare Workers (support-api.elvisli.ca): Stateless backend for subscribe, unsubscribe, and admin endpoints
  • Cloudflare D1: SQLite database storing subscribers with geo-location metadata
  • Cloudflare Pages: Frontend for admin dashboard and unsubscribe pages (7 locales)
  • Resend: Free tier email API (280 emails/day) for transactional sends
  • Web Crypto API: Browser + Workers native HMAC-SHA256 for token signing

Total cost: $0 per month. (If you exceed Resend's free tier, upgrade to paid; no operational burden at any scale.)


Database Schema: Subscribers + Geo

The heart is a single subscribers table in D1:

CREATE TABLE IF NOT EXISTS subscribers (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  source TEXT DEFAULT 'home',           -- where they signed up
  lang TEXT,                             -- user's language preference
  country TEXT, region TEXT, city TEXT, -- Cloudflare edge-injected geo
  timezone TEXT,                         -- auto-populated by CF
  ip TEXT, user_agent TEXT,              -- for audit
  status TEXT DEFAULT 'active',          -- 'active' or 'unsubscribed'
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_email ON subscribers(email);
CREATE INDEX IF NOT EXISTS idx_status ON subscribers(status);

Why this schema?

  • Email UNIQUE: Prevents duplicate signups; resubscribe attempts silently fail with fake success (anti-spam)
  • Geo columns: Cloudflare Workers sees request.cf at the edge, so you get city/country/timezone for free — no third-party IP lookup API needed
  • Status enum: Soft-delete pattern; unsubscribe sets status='unsubscribed', doesn't delete (audit trail)
  • source: Track where signups came from (homepage, email forwarding, etc.)

Backend: Workers API

The backend is a single Worker file handling three endpoints:

1. POST /api/subscribe — New Subscriber

Request:

{
  "email": "user@example.com",
  "lang": "zh",
  "source": "home",
  "website": "",              // honeypot (hidden from UI)
  "elapsedMs": 5000           // form load → submit timing
}

Flow:

async function handleSubscribe(request, env) {
  const body = await request.json();
  const { email, lang, source, website, elapsedMs } = body;

  // 1. Anti-spam: silent bot rejection (no visible challenge)
  if (website || elapsedMs < 1200) {
    return fakeSuccess(); // bots get fake 200, don't know they're blocked
  }

  // 2. Validate email
  if (!isValidEmail(email)) return error400("Invalid email");

  // 3. Capture geo from Cloudflare edge
  const cf = request.cf || {};
  const country = cf.country || request.headers.get("CF-IPCountry");
  const timezone = cf.timezone;
  const ip = request.headers.get("CF-Connecting-IP");

  // 4. Insert into D1 (UNIQUE email prevents duplicates)
  const result = await env.DB.prepare(
    `INSERT OR IGNORE INTO subscribers (email, lang, source, country, timezone, ip)
     VALUES (?, ?, ?, ?, ?, ?)`
  ).bind(email, lang, source, country, timezone, ip).run();
  
  const isNew = result.meta.changes > 0;

  // 5. Send welcome email (localized, only for new subscribers)
  if (isNew) {
    const unsubToken = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
    const unsubUrl = `https://elvisli.ca/${localePrefix(lang)}/unsubscribe?email=${email}&token=${unsubToken}`;
    
    await sendResendEmail({
      to: email,
      subject: welcomeCopy(lang).subject,
      html: welcomeEmailHtml(lang, unsubUrl),
    });

    // Also notify the site owner
    await sendResendEmail({
      to: env.NOTIFY_EMAIL,
      subject: `📨 New subscriber: ${email}`,
      html: adminNotificationHtml(email, country, timezone, lang),
    });
  }

  return ok({ success: true, isNew, subscribed: true });
}

Key insights:

  • Honeypot + timing guard: Bots either fill the hidden website field or submit in < 1.2 seconds. Both get fake success (200 response but no DB insert) so they don't probe again.
  • Geo from request.cf: Cloudflare automatically populates this at the edge; no third-party API call needed.
  • HMAC token generation: Happens at subscribe time and baked into the unsubscribe URL, sent in the email. This is the security crux—see below.

2. POST /api/unsubscribe — Self-Service Opt-Out

Request:

{
  "email": "user@example.com",
  "token": "a3f8d2c6..."  // HMAC-SHA256(email, UNSUBSCRIBE_SECRET)
}

Flow:

async function handleUnsubscribe(request, env) {
  const { email, token } = await request.json();

  // Verify HMAC signature (prevents third-party unsubscribe)
  const expected = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
  if (token !== expected) {
    return error403("Invalid or expired unsubscribe link");
  }

  // Mark as unsubscribed (don't delete—audit trail)
  await env.DB.prepare(
    `UPDATE subscribers SET status = 'unsubscribed' WHERE email = ?`
  ).bind(email).run();

  return ok({ success: true });
}

Why HMAC, not a one-time token?

  • Stateless: No need to store a DB table of "unsubscribe tokens" that expires after 7 days. The signature itself is the proof.
  • Bound to email: HMAC(email, secret) always produces the same signature for that email. You can't unsubscribe attacker@malicious.com with the signature for victim@example.com.
  • Constant-time comparison: Compare signatures via bitwise XOR to prevent timing attacks.

Flow:

// Constant-time HMAC verification
async function verifyUnsubToken(email, token, secret) {
  const expected = await hmacToken(email, secret);
  if (expected.length !== token.length) return false;
  
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    diff |= expected.charCodeAt(i) ^ token.charCodeAt(i);
  }
  return diff === 0; // timing-safe comparison
}

3. GET /api/admin/subscribers — Admin Report

Protected by Authorization: Bearer ${ADMIN_TOKEN}:

const { results } = await env.DB.prepare(
  `SELECT id, email, source, lang, country, city, timezone, status, created_at
   FROM subscribers WHERE status = 'active' ORDER BY created_at DESC`
).all();

return json({ total: results.length, subscribers: results });

Admin can then delete a record via DELETE /api/admin/subscribers/:id with the same token.


Multi-Language Welcome Email

The welcome email is critical for onboarding and contains the unsubscribe link. It must:

  1. Build trust — branded, professional, honest
  2. Set expectations — what will they receive and how often
  3. Offer escape — prominent unsubscribe link so they never feel trapped

Here's the template structure:

const WELCOME_I18N = {
  en: {
    subject: "You're subscribed — elvisli.ca",
    heading: "You're in.",
    body: "Thanks for subscribing. You'll get new writing on AI, building things, and the occasional experiment — straight to your inbox. No spam, no noise.",
    cta: "Read the latest →",
    footer: "You're receiving this because someone subscribed this email at elvisli.ca.",
    notyou: "Didn't sign up?",
    unsub: "Unsubscribe instantly",
  },
  zh: {
    subject: "订阅成功 — elvisli.ca",
    heading: "订阅成功 👋",
    body: "感谢订阅。你将收到关于 AI、动手构建以及一些实验的新文章,直接送达收件箱。不发垃圾邮件,不打扰。",
    cta: "看看最新文章 →",
    footer: "你收到这封邮件,是因为有人用此邮箱在 elvisli.ca 订阅了更新。",
    notyou: "不是你本人订阅的?",
    unsub: "立即取消订阅",
  },
  // ... fr, es, ja, ko, zh-TW
};

function welcomeEmailHtml(lang, unsubUrl) {
  const t = welcomeCopy(lang);
  const blog = "https://elvisli.ca/blog";
  
  return `
  <div style="padding:32px 16px;background:#f5f5f5;font-family:system-ui;">
    <div style="max-width:480px;margin:0 auto;background:#fff;border:1px solid #e5e5e5;border-radius:12px;">
      <div style="padding:36px;">
        <p style="font-size:11px;letter-spacing:0.12em;color:#888;text-transform:uppercase;">
          ${t.eyebrow}
        </p>
        <h1 style="font-size:26px;font-weight:700;margin:14px 0;">${t.heading}</h1>
        <p style="font-size:15px;line-height:1.65;color:#555;margin:0 0 28px;">
          ${t.body}
        </p>
        <a href="${blog}" style="display:inline-block;background:#A3E635;color:#000;font-weight:600;padding:11px 22px;border-radius:8px;text-decoration:none;">
          ${t.cta}
        </a>
      </div>
      <div style="border-top:1px solid #eee;padding:18px 36px;background:#fafafa;font-size:12px;">
        <p style="color:#999;margin:0 0 8px;">${t.footer}</p>
        <p style="color:#999;">
          ${t.notyou}
          <a href="${unsubUrl}" style="color:#c0392b;font-weight:600;text-decoration:underline;">
            ${t.unsub}
          </a>
        </p>
      </div>
    </div>
  </div>`;
}

Design notes:

  • Light theme: Dark emails get stuck in spam folders. Light background, black text, good contrast.
  • Lime accent (#A3E635): Matches your site's design language.
  • Prominent "Didn't sign up?" line: Red, bold, at the bottom—makes it clear the door is always open. If someone maliciously subscribed a visitor's address, the visitor sees this immediately and can opt out.
  • Mono eyebrow: Technical credibility; appeals to hackers/builders.

Frontend: Unsubscribe Page (7 Locales)

Each unsubscribe page (/[locale]/unsubscribe) is read-only:

export default function UnsubscribePage() {
  const [email, setEmail] = useState('');
  const [token, setToken] = useState('');
  const [status, setStatus] = useState('init');

  useEffect(() => {
    // Email + token are read ONLY from the signed link
    const params = new URLSearchParams(window.location.search);
    setEmail(params.get('email'));
    setToken(params.get('token'));
  }, []);

  const handleUnsubscribe = async () => {
    const res = await fetch('https://support-api.elvisli.ca/api/unsubscribe', {
      method: 'POST',
      body: JSON.stringify({ email, token }),
    });
    
    if (res.ok) setStatus('done');
    else if (res.status === 403) setStatus('invalid');
  };

  return (
    <div>
      <p>You're about to unsubscribe this address:</p>
      {/* Email is locked and read-only */}
      <div style={{ background: '#f5f5f5', padding: '11px', borderRadius: '8px', fontFamily: 'monospace' }}>
        {email}
      </div>
      <button onClick={handleUnsubscribe}>Confirm Unsubscribe</button>
    </div>
  );
}

Why read-only email?

The HMAC token is bound to this exact email address. If the user tries to change it to attacker@evil.com, the signature no longer matches, and the backend returns 403. So the editable field was:

  1. Confusing (users thought they could unsub a different address)
  2. Pointless (the backend would reject it anyway)

Lock it and reduce cognitive load.


Admin Dashboard

The frontend admin has a "Subscribers" tab where you can:

  • View all: Email, location (city/region/country), timezone, language, source, signup date
  • Search & filter: By email, location, language
  • Edit mode: Click "Edit" to reveal a delete button (two-button unlock to prevent accidental deletion)
  • Delete: Single subscribers via the "Delete" button
<table>
  <thead>
    <tr>
      <th>Email</th>
      <th>Location</th>
      <th>Language</th>
      <th>Source</th>
      <th>Subscribed At</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr id="sub-row-5">
      <td>elvis@example.com</td>
      <td>Toronto, Ontario, CA</td>
      <td><span class="badge">en</span></td>
      <td>home</td>
      <td>2026-06-18 12:34:56</td>
      <td>
        <button onclick="toggleSubEdit(5)">Edit</button>
        <span id="sub-actions-5" style="display:none;">
          <button onclick="deleteSubscriber(5, 'elvis@example.com')">Delete</button>
          <button onclick="toggleSubEdit(5)">Cancel</button>
        </span>
      </td>
    </tr>
  </tbody>
</table>

The delete button is hidden by default (only shows after clicking Edit), preventing fat-finger accidents.


Secrets & Environment

Store all sensitive data in Cloudflare Secrets, never in wrangler.toml:

npx wrangler secret put ADMIN_TOKEN
npx wrangler secret put UNSUBSCRIBE_SECRET
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put NOTIFY_EMAIL
npx wrangler secret put NOTIFY_FROM

In wrangler.jsonc (NOT secrets, public-ish):

[vars]
ALLOWED_ORIGIN = "*"

[[d1_databases]]
binding = "DB"
database_name = "newsletter-db"
database_id = "your-d1-uuid"

Deployment Checklist

  1. Create D1 database:

npx wrangler d1 create newsletter-db
npx wrangler d1 execute newsletter-db --local --file=schema.sql
npx wrangler d1 execute newsletter-db --remote --file=schema.sql
  • Set secrets:

  • npx wrangler secret put ADMIN_TOKEN        # random string, e.g., openssl rand -hex 32
    npx wrangler secret put UNSUBSCRIBE_SECRET # same
    npx wrangler secret put RESEND_API_KEY     # from https://resend.com
    npx wrangler secret put NOTIFY_EMAIL       # your email
    
  • Deploy Worker:

  • npx wrangler deploy
    
  • Deploy Unsubscribe Pages (Next.js):

  • npm run deploy
    
  • Test:

  • curl -X POST https://support-api.elvisli.ca/api/subscribe \
      -H "Content-Type: application/json" \
      -d '{"email":"test@example.com","lang":"en","source":"test"}'
    

    Security Checklist

    • [ ] Email addresses in unsubscribe URLs are URL-encoded (encodeURIComponent)
    • [ ] HMAC tokens use constant-time comparison (prevent timing attacks)
    • [ ] All user input in HTML emails is escaped (prevent XSS in email clients)
    • [ ] Honeypot + timing guard reject bots silently (no error messages that leak info)
    • [ ] UNSUBSCRIBE_SECRET is at least 32 random bytes (strong HMAC key)
    • [ ] D1 schema uses UNIQUE on email (prevent duplicates at DB level)
    • [ ] Unsubscribe links include the locale prefix so they don't 404
    • [ ] Welcome emails only send on the first signup, not on re-subscription

    The Full Picture

    When a visitor subscribes:

    1. Frontend form captures email + language + honeypot/timing
    2. POST /api/subscribe validates, anti-spam checks, geo capture
    3. D1 insert (UNIQUE constraint dedupes; resubscribe rejects silently)
    4. HMAC token generation for this specific email
    5. Welcome email sent to subscriber (localized) with signed unsubscribe URL
    6. Admin notification email sent to you with subscriber geo + transient unsub link

    When the subscriber clicks unsubscribe:

    1. Unsubscribe page (/[locale]/unsubscribe?email=...&token=...) displays their email (read-only)
    2. Click confirm → POST /api/unsubscribe with email + token
    3. Backend verifies HMAC — if token ≠ HMAC(email, secret), return 403
    4. D1 update sets status='unsubscribed'
    5. Confirmation page shows "You've been unsubscribed"

    The entire system is stateless, scalable, and costs $0 because everything runs on Cloudflare's free tier.


    Next Steps

    • Integrate with your newsletter sender (e.g., loop over SELECT * FROM subscribers WHERE status='active' to build your mailing list each week)
    • Add a campaigns table to track opens/clicks (use redirect URLs like https://support-api.elvisli.ca/click?campaign_id=...&subscriber_id=...)
    • Sync with a CRM via API (e.g., pull into Mailchimp for segmentation)
    • A/B test welcome email copy and measure unsubscribe rates

    The foundation you've built is rock-solid. From here, you own your audience.


    Live demo: Subscribe at elvisli.ca/en and check your inbox for the welcome email.