Skip to content

feat(v2.0b): newsletter — fully optional (closes part of #35)#44

Merged
thunpisit merged 1 commit into
mainfrom
feat/v2.0b-newsletter
May 2, 2026
Merged

feat(v2.0b): newsletter — fully optional (closes part of #35)#44
thunpisit merged 1 commit into
mainfrom
feat/v2.0b-newsletter

Conversation

@thunpisit

Copy link
Copy Markdown
Contributor

Summary

Second of four PRs for v2.0. Newsletter shipping with everything optional so operators can adopt at their own pace.

Three operator levels

Level Operator config Public behavior
0 nothing Subscribe form returns 503 (or single-opt-in mode if `allowSingleOptIn` is checked, default)
1 Resend key + sender in `/cms/settings` Real double-opt-in email via Resend
2 Cron-trigger → `/api/newsletter/send-digest` Automated weekly digest

What ships

  • Schema (migration 0008): `subscribers` (email UNIQUE, locale, token UNIQUE, confirmedAt, unsubscribedAt, source).
  • 8 new ContentProvider methods (idempotent confirm/unsubscribe).
  • Newsletter helper module: `readNewsletterConfig`, `isProviderConfigured`, `sendEmail` (Resend), `buildConfirmEmail`, `buildDigestEmail`.
  • Public endpoints: `POST /api/newsletter/subscribe`, `GET /api/newsletter/{confirm,unsubscribe}`. Honeypot + per-IP rate-limit-ready. One-click unsubscribe — no interstitial.
  • Admin endpoint: `POST /api/newsletter/send-digest?days=7&dryRun=1`.
  • CMS `/cms/subscribers` (admin+) with provider-status banner + manual digest trigger.
  • CMS `/cms/settings` gets a "Newsletter (optional)" card.
  • New audit actions: `newsletter.{subscribe,confirm,unsubscribe,delete,digest_sent}`.
  • 22 new i18n keys (EN + TH).
  • Migration 0008 already applied to live D1.

Roadmap state after this merges

  • ✅ v2.0a Forms
  • ✅ v2.0b Newsletter (this PR)
  • 🚧 v2.0c Comments
  • 🚧 v2.0d Webhooks + Public REST API

Test plan

  • `pnpm build` succeeds
  • `paraglide compile` succeeds
  • `svelte-check` clean (paraglide-server noise unchanged)
  • After deploy without provider: `curl -X POST /api/newsletter/subscribe -F email=test@example.com` → 200 with `mode: "single-opt-in"`
  • Set Resend key + sender in `/cms/settings` → same call sends a real confirmation email, returns `mode: "double-opt-in"`
  • Click confirm link → 302 to `/{locale}/?newsletter=confirmed`, subscriber row's `confirmedAt` populated
  • `/cms/subscribers` shows the row with "active" badge
  • "Dry run" button reports correct count + per-locale article counts

🤖 Generated with Claude Code

Second of four v2.0 PRs. Newsletter is wired so it works at three
levels of operator commitment:

  Level 0 (default): no provider configured
    → public subscribe form returns 503 OR (if `allowSingleOptIn` is
      checked, default true) creates rows immediately confirmed.
      Operator can manually export/email later.
  Level 1: Resend API key + sender configured
    → public subscribe sends a real double-opt-in email.
      Subscribers active only after clicking the confirmation link.
  Level 2: cron-trigger pointing at /api/newsletter/send-digest
    → automated weekly digest (operator wires the cron in their
      wrangler.toml; we ship the endpoint).

Schema (Drizzle migration 0008)
-------------------------------
- subscribers: id, email UNIQUE, locale, token UNIQUE (24-char
  nanoid for confirm + unsubscribe links), confirmedAt nullable,
  unsubscribedAt nullable, source, createdAt.

Provider
--------
- ContentProvider gains 8 newsletter methods: list/count/getByEmail/
  getByToken/create (with autoConfirm flag for single-opt-in mode)/
  confirm/unsubscribe/delete. All idempotent where it matters.

Newsletter helper module ($lib/server/newsletter/index.ts)
----------------------------------------------------------
- readNewsletterConfig(settings): pulls newsletter.* keys from
  site_settings.
- isProviderConfigured(cfg): true when both resendKey + senderAddress
  are present.
- sendEmail(cfg, args): Resend POST. Returns { ok: true, id? } or
  { ok: false, reason }. Best-effort — never throws.
- buildConfirmEmail({...}): per-locale subject + html + text.
- buildDigestEmail({...}): per-locale weekly digest with embedded
  unsubscribe link.

Public endpoints
----------------
- POST /api/newsletter/subscribe (honeypot, idempotent on existing
  emails to prevent enumeration, audit-logged)
- GET /api/newsletter/confirm?token=... (idempotent → 302 to
  localized home with ?newsletter=confirmed)
- GET /api/newsletter/unsubscribe?token=... (one-click, no
  interstitial — GDPR/CAN-SPAM)

Admin endpoint
--------------
- POST /api/newsletter/send-digest?days=7&dryRun=1 (admin-only,
  iterates active subscribers, groups by locale, sends via Resend.
  Returns { ok, sent, failed } or { ok, dryRun: true, subscribers,
  articleCounts }. 503 when no provider configured.)

CMS surface
-----------
- /cms/subscribers (admin+): list with status badges (pending /
  active / unsubscribed), provider-status banner, manual "Send
  digest" + dry-run button when provider is on. Sidebar gets a
  "Subscribers" entry under Admin.
- /cms/settings: new "Newsletter (optional)" card with three
  fields: resendKey (masked-ish, font-mono), senderAddress, and
  a checkbox for the allow-single-opt-in fallback behavior.

Audit
-----
- New AuditAction members: newsletter.{subscribe,confirm,
  unsubscribe,delete,digest_sent}.

i18n: 22 new keys (EN + TH) across cms_subscribers_*,
cms_settings_newsletter_*.

Migration 0008 already applied to live D1.

Roadmap state
-------------
v2.0a Forms ✅ (PR #43)
v2.0b Newsletter ✅ (this PR)
v2.0c Comments 🚧
v2.0d Webhooks + Public REST API 🚧

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@thunpisit thunpisit merged commit 3f52dbc into main May 2, 2026
@thunpisit thunpisit deleted the feat/v2.0b-newsletter branch May 2, 2026 04:15
thunpisit added a commit to codustry/khaopad-example that referenced this pull request May 2, 2026
…y#44) (#12)

Cherry-picks upstream PR codustry#44. Migration 0008 already applied to live D1.
i18n: 22 new keys field-merged into messages/en.json + th.json without
overwriting example-specific copy.

Co-authored-by: Claude Opus 4.7 <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