Skip to content

Email capture: standalone Cloudflare Worker + D1 (lighter variant of #26) - #127

Merged
unforced merged 3 commits into
mainfrom
feat-email-capture-worker
Jun 24, 2026
Merged

Email capture: standalone Cloudflare Worker + D1 (lighter variant of #26)#127
unforced merged 3 commits into
mainfrom
feat-email-capture-worker

Conversation

@unforced

Copy link
Copy Markdown
Contributor

What & why

A SIMPLE place to capture visitor emails for later sync into a Parachute vault — "start simple, just a place to capture emails." A quiet "Stay in the loop" form in the home's closing section posts to our own store.

This is the lighter variant of #26: the site stays on GitHub Pages (no apex DNS migration). Only the email-capture backend goes to Cloudflare — a standalone Worker + D1, instead of #26's Pages Functions (which would have required moving hosting to CF Pages).

How it differs from #26's Pages Function

  • Repackaged onRequestPost: PagesFunction → a Worker export default { async fetch(request, env) } (worker/subscribe.ts). Same email validate/normalize (EMAIL_RE, 254 cap), source-path-from-Referer, parameterized D1 insert, two reserved columns.
  • Absolute redirects — the Worker is a different origin from the site, so Location must be absolute (https://parachute.computer/subscribe/thanks/ on success, …/?subscribe_error=1 on failure). A relative path would resolve against the Worker origin.
  • No CORS — the form is a native cross-origin <form method="POST">; the browser follows the Worker's 303 itself and the page never reads the cross-origin response with JS, so there's no preflight and no CORS headers needed.
  • wrangler.toml adapted for a Worker (main = "worker/subscribe.ts" + [[d1_databases]] DB binding, database_id = "PLACEHOLDER"), not pages_build_output_dir.
  • Dropped V1 interest list — homepage subscribe form, Pages Function, D1 #26's hosting-migration parts (no GH Pages workflow archival, no apex DNS swap, no CF Pages project).

The form

Quiet, serif/muted block in the home-close section (below "Get started", above the thin footer). One email input + a Subscribe button reusing .home-cta + existing tokens. Endpoint is a single documented constant: _data/site.jsonsubscribeEndpoint (default https://subscribe.parachute.computer/), referenced as {{ site.subscribeEndpoint }}. Kept the tiny inline ?subscribe_error=1 script that reveals a discreet error message.

Files

  • worker/subscribe.ts, worker/tsconfig.json
  • migrations/0001_interests.sql (verbatim from V1 interest list — homepage subscribe form, Pages Function, D1 #26)
  • wrangler.toml
  • _data/site.json
  • subscribe/thanks.njk
  • index.njk (form + scoped CSS + error script)
  • eleventy.config.js (ignore worker/, migrations/, wrangler.toml, DEPLOY-subscribe.md)
  • package.json (+ wrangler, @cloudflare/workers-types; dev:worker / db:migrate)
  • DEPLOY-subscribe.md ("what's left for Aaron")

What's left for Aaron (Cloudflare)

wrangler loginwrangler d1 create parachute-interests (paste database_id into wrangler.toml) → wrangler d1 migrations apply parachute-interests --remotewrangler deploy → set custom domain subscribe.parachute.computer (or update _data/site.json to the workers.dev URL) → smoke-test a real submission + confirm a D1 row. Full checklist in DEPLOY-subscribe.md.

Verification

  • npx @11ty/eleventy builds clean; _site/index.html form posts to the configured endpoint; _site/subscribe/thanks/index.html exists; worker/, migrations/, wrangler.toml are not copied into _site/.
  • Worker typechecks (tsc --noEmit strict against @cloudflare/workers-types, clean) and bundles via wrangler deploy --dry-run (2.43 KiB, D1 binding recognized).
  • Rendered home + error state + thanks page verified in a real browser (HTTP-served so style.css resolves).

Security

  • Worker holds no secret beyond the D1 binding (DB); insert is parameterized.
  • Permissive email validation (typo-catching, not RFC 5322).
  • No rate limiting yet — known simple-start gap, fine for early volume; add Turnstile / WAF rate-limit on the route when it matters (no code redeploy for a WAF rule).

🤖 Generated with Claude Code

unforced and others added 3 commits June 24, 2026 10:28
Adds a quiet "stay in the loop" interest-list form to the home's closing
section. The site stays on GitHub Pages (no apex DNS migration); only the
email-capture backend lives on Cloudflare, as a small standalone Worker
backed by D1.

- worker/subscribe.ts — Worker (export default { fetch }) repackaged from
  PR #26's Pages Function. Validates/normalizes the email (EMAIL_RE, 254
  cap), inserts into D1, 303-redirects to ABSOLUTE site URLs (thanks/ on
  success, /?subscribe_error=1 on failure). GET = health check.
- migrations/0001_interests.sql — interests table (verbatim from #26),
  two reserved columns (user_id, resend_contact_id) for later.
- wrangler.toml — Worker config (main + [[d1_databases]] DB binding,
  database_id PLACEHOLDER for Aaron).
- index.njk — native cross-origin <form method="POST"> (no CORS needed:
  browser follows the 303 itself) in the home-close section, endpoint via
  {{ site.subscribeEndpoint }}; reuses .home-cta + tokens; tasteful CSS;
  kept the inline ?subscribe_error=1 error script.
- _data/site.json — subscribeEndpoint constant (default
  https://subscribe.parachute.computer/).
- subscribe/thanks.njk — thank-you page (extends current base.njk).
- eleventy.config.js — ignore worker/, migrations/, wrangler.toml,
  DEPLOY-subscribe.md so the static build skips backend assets.
- package.json — wrangler + @cloudflare/workers-types devDeps;
  dev:worker + db:migrate scripts.
- DEPLOY-subscribe.md — "what's left for Aaron" Cloudflare checklist.

Known gap: no rate limiting yet (accepted simple-start). No secrets
beyond the D1 binding; parameterized insert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rce_path caveat

- migrations: fix stale "Pages Function" comment → "subscribe Worker";
  document the unpopulated `name` column + source_path-often-NULL behavior
- DEPLOY-subscribe.md: note source_path is usually NULL (cross-origin
  Referrer-Policy strips the path); emphasize committing the real
  database_id out of the placeholder

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Provisioned the Cloudflare side (Unforced Development account):
- D1 `parachute-interests` created; migration 0001 applied (remote).
- Worker `parachute-subscribe` deployed → https://parachute-subscribe.unforced.workers.dev
- wrangler.toml: real database_id + pinned account_id (non-interactive deploys).
- _data/site.json: subscribeEndpoint → the deployed workers.dev URL.

Smoke-tested end-to-end: POST → 303 /subscribe/thanks/ → row in D1 (then
deleted the test row; count=0). A custom domain (subscribe.parachute.computer)
can replace the workers.dev URL later via one site.json edit + a CF custom domain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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