Skip to content

Newsletter: Listmonk integration, double opt-in signup, two-way unsubscribe sync - #170

Merged
JanMikes merged 5 commits into
mainfrom
feature/newsletter-listmonk
Jul 26, 2026
Merged

Newsletter: Listmonk integration, double opt-in signup, two-way unsubscribe sync#170
JanMikes merged 5 commits into
mainfrom
feature/newsletter-listmonk

Conversation

@JanMikes

Copy link
Copy Markdown
Member

What

Everything needed to send the first MySpeedPuzzling newsletter via Listmonk. MySpeedPuzzling is the source of truth for who is subscribed; Listmonk is a mirror + send engine. Full docs: docs/features/newsletter/README.md.

Signup (guests) — footer form on every page

  • New NewsletterSubscriber entity + migration, double opt-in (branded confirmation e-mail, 48h token)
  • Stateless CSRF token ids (config/packages/csrf.php) so the footer form does NOT start sessions on anonymous pages — shared caching stays intact (guarded by existing tests)
  • Rate limited per address (3/15min) and per IP (20/h); logged-in players see a settings link instead

Unsubscribe — works from anywhere, syncs everywhere

  • Every newsletter carries a per-recipient signed MySpeedPuzzling unsubscribe URL → landing page with exactly two options: one-click unsubscribe (POST, scanner-safe) and manage notification settings
  • Tokens: stateless HMAC (NewsletterTokenSigner), no expiry, die on e-mail change
  • Listmonk's RFC 8058 List-Unsubscribe one-click headers (Gmail/Yahoo) land in Listmonk and are pulled into MSP by the cron within 15 min

Sync — myspeedpuzzling:sync-newsletter-subscribers (cron */15)

  • 6 per-locale lists (Newsletter EN/CS/DE/ES/FR/JA), auto-created by name, subscriber attribs carry locale + unsubscribe/manage URLs
  • Pull: Listmonk unsubscribes → newsletterEnabled=false / guest unsubscribed
  • Push: creates (bulk CSV import >50 — the initial ~10k import), drift updates, MSP unsubscribes → Listmonk suppression, deleted players removed from Listmonk (plus immediate removal from DeletePlayerHandler)
  • The cron never flips a Listmonk unsubscribe back to confirmed — only explicit user actions do (verified against Listmonk semantics empirically)
  • Pure diff planner (NewsletterSyncPlanner) with 13 unit tests; blocklisted (bounced) subscribers and foreign-list memberships are never touched

Verified end-to-end in dev

Footer signup → confirmation e-mail → confirm → appears in Listmonk; unsubscribe page → Listmonk suppression; Listmonk-side unsub → pulled into MSP; deleted row → removed from Listmonk; sync idempotent on 8.3k subscribers (second run touches nothing).

Production (already prepared, activates on merge)

  • 6 lists + campaign template already created on prod Listmonk via API; test campaign sent
  • lily.srv repo: LISTMONK_API_URL env + cron entry pushed (installs on this deploy)
  • APP_SECRET fix: prod ran with the baked dummy kernel secret (Infisical never set it). A strong secret is now in Infisical and lands with this deploy. Impact was limited to e-mail-verification link signing (login links + password resets are DB-backed random tokens). In-flight verification links (<24h) break once at deploy.

All 6 locales

UI pages, footer form, confirmation e-mail and the Listmonk template footer are localized in en/cs/de/es/fr/ja.

Quality gates

PHPStan max ✅ · PHPCS ✅ · 1418 tests ✅ (incl. 20 new) · schema validate ✅ · cache warmup ✅ · translations check 0 missing ✅

🤖 Generated with Claude Code

https://claude.ai/code/session_01GVCsN9ELLwdwNPEXpGUcnt

JanMikes and others added 5 commits July 26, 2026 12:09
…y unsubscribe sync

MySpeedPuzzling is the source of truth, Listmonk mirrors it:

- NewsletterSubscriber entity for guests from the new footer signup form
  (double opt-in, stateless CSRF so anonymous pages stay session-free,
  rate-limited per address and IP)
- Per-recipient signed unsubscribe links land on a two-option page
  (one-click unsubscribe / manage notification settings); tokens are
  stateless HMAC claims with no expiry that die on e-mail change
- myspeedpuzzling:sync-newsletter-subscribers (cron */15) reconciles both
  directions: pulls Listmonk one-click-header unsubscribes into MSP,
  pushes creates (bulk CSV import for the initial ~10k), updates,
  unsubscribes and removals of deleted players; never re-confirms a
  Listmonk unsubscribe - only explicit user actions do
- Immediate async pushes from profile toggle, opt-in confirm, unsubscribe
  page and DeletePlayerHandler (GDPR removal from Listmonk)
- 6 per-locale lists auto-created by name; branded campaign template with
  localized footer versioned in docs/features/newsletter/
- All UI and e-mail copy in all six locales

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVCsN9ELLwdwNPEXpGUcnt
…cing texts

Footer pitch, opt-in confirmation e-mail and the profile help text now say
the newsletter is only about what's new on the platform - all six locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVCsN9ELLwdwNPEXpGUcnt
Links list keeps the left column; the signup block (capped at 430px,
right-aligned on desktop, centered on mobile) sits above the social
icons, which now carry title tooltips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVCsN9ELLwdwNPEXpGUcnt
@JanMikes
JanMikes merged commit 6b4829c into main Jul 26, 2026
5 checks passed
@JanMikes
JanMikes deleted the feature/newsletter-listmonk branch July 26, 2026 17:58
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