Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ MAILER_TRANSACTIONAL_DSN=smtp://mailer:1025
MAILER_NOTIFICATIONS_DSN=smtp://mailer:1025
BOUNCE_EMAIL_DOMAIN=

###> listmonk (newsletter engine) ###
# Empty LISTMONK_API_TOKEN disables the whole integration (closed-by-default).
# Dev credentials are seeded by the listmonk-seed compose service; production
# values come from Infisical (api-msp-web user on listmonk.myspeedpuzzling.com).
LISTMONK_API_URL=http://listmonk:9000
LISTMONK_API_USER=api-dev
LISTMONK_API_TOKEN=MVNawj1IBdVcCPvwfTd5GSsjT44HUMot
###< listmonk ###

STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=

Expand Down
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ DATABASE_URL="postgresql://postgres:postgres@postgres:5432/speedpuzzling_test?se

LOCK_DSN="in-memory"

# Tests must never talk to a real Listmonk - empty token disables the integration
LISTMONK_API_TOKEN=

# Trickle branch is exercised in tests against the PredictableTrickleVerifier
# test double (config/services_test.php) - never against the real Auth0 tenant
AUTH0_TRICKLE_LOGIN_ENABLED=1
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Feature design documents and implementation plans are in `docs/features/`. Each
- **Opt-Out Features**: `docs/features/opt-out.md` — Streak and ranking opt-out for players
- **Competitions Management**: `docs/features/competitions-management/` — Community-driven event creation with admin approval, round management, puzzle assignment, table layout planning, and live stopwatch
- **Referral Program**: `docs/features/referral-program.md` — Members earn 10% of referred subscription revenue. No separate entity — `player.referralProgramJoinedAt` + `player.referralProgramSuspended`. Code = player code. Cookie-based + code-input attribution. Payouts per currency, manual admin payout marking
- **Newsletter (Listmonk)**: `docs/features/newsletter/README.md` — MySpeedPuzzling is the source of truth, Listmonk mirrors it. 6 per-locale lists. Guests subscribe via footer form (double opt-in, `NewsletterSubscriber` entity); players via `player.newsletterEnabled`. **Cron `*/15 * * * * myspeedpuzzling:sync-newsletter-subscribers`** reconciles both ways: pulls Listmonk unsubscribes (one-click header) into MSP, pushes creates/updates/unsubscribes, and **removes deleted players from Listmonk** (`DeletePlayerHandler` also queues immediate removal). The cron never re-confirms a Listmonk unsubscribe — only explicit user actions do. Campaign template versioned at `docs/features/newsletter/listmonk-campaign-template.html`

### Feature Flags
Active feature flags are documented in `docs/features/feature_flags.md`. **Always read and update this file** when adding, modifying, or removing feature flags. It tracks which files are gated, what feature each flag belongs to, and when it can be removed.
Expand Down
20 changes: 18 additions & 2 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,20 @@ services:
postgres:
condition: service_healthy

# Idempotently seeds the api-dev API user into Listmonk so the LISTMONK_*
# dev defaults in .env work out of the box (tokens are stored in plain text
# in the listmonk users table, so the row can be seeded deterministically)
listmonk-seed:
image: postgres:16.0
environment:
PGPASSWORD: postgres
command: >
sh -c "i=0; until psql -h postgres -U postgres -d listmonk -tc 'SELECT 1 FROM users' >/dev/null 2>&1; do i=$$((i+1)); [ $$i -ge 60 ] && exit 1; sleep 1; done;
psql -h postgres -U postgres -d listmonk -c \"INSERT INTO users (username, password_login, password, email, name, type, user_role_id, status) SELECT 'api-dev', false, 'MVNawj1IBdVcCPvwfTd5GSsjT44HUMot', 'api-dev@api', 'API Dev', 'api', 1, 'enabled' WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'api-dev')\""
depends_on:
listmonk:
condition: service_started

listmonk:
image: listmonk/listmonk:latest
restart: unless-stopped
Expand All @@ -193,8 +207,10 @@ services:
environment:
TZ: Europe/Prague
LISTMONK_app__address: 0.0.0.0:9000
LISTMONK_app__admin_username: admin
LISTMONK_app__admin_password: admin
# Install-time bootstrap of the super admin (only takes effect when
# `--install` runs against an empty database)
LISTMONK_ADMIN_USER: admin
LISTMONK_ADMIN_PASSWORD: adminadmin
LISTMONK_db__host: postgres
LISTMONK_db__port: 5432
LISTMONK_db__user: postgres
Expand Down
6 changes: 6 additions & 0 deletions config/packages/csrf.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
// Same reason: the "forgot password" form is rendered for anonymous
// visitors, and a session-backed token would put a cookie on the page
'request_password_reset',
// The newsletter signup form sits in the footer of EVERY anonymous
// page - a session-backed token would kill shared caching site-wide
'newsletter-subscribe',
// Unsubscribe landing page is reached from e-mail links, always
// anonymous
'newsletter-unsubscribe',
],
],
],
Expand Down
5 changes: 5 additions & 0 deletions config/packages/messenger.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use SpeedPuzzling\Web\Message\PrepareDigestEmailForPlayer;
use SpeedPuzzling\Web\Message\PushNewsletterSubscriberToListmonk;
use SpeedPuzzling\Web\Message\RecalculateDerivedMetricsForPuzzle;
use SpeedPuzzling\Web\Message\RemoveNewsletterSubscriberFromListmonk;
use Symfony\Component\Mailer\Messenger\SendEmailMessage;

return App::config([
Expand Down Expand Up @@ -43,6 +45,9 @@
SendEmailMessage::class => 'async',
PrepareDigestEmailForPlayer::class => 'async',
RecalculateDerivedMetricsForPuzzle::class => 'async',
// Listmonk API calls must not block or fail the user-facing request
PushNewsletterSubscriberToListmonk::class => 'async',
RemoveNewsletterSubscriberFromListmonk::class => 'async',
// Events that must run synchronously for immediate UI updates (Turbo Streams)
'SpeedPuzzling\Web\Events\PuzzleBorrowed' => 'sync',
'SpeedPuzzling\Web\Events\PuzzleAddedToCollection' => 'sync',
Expand Down
13 changes: 13 additions & 0 deletions config/packages/rate_limiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@
'limit' => 3,
'interval' => '15 minutes',
],
// The public newsletter signup form mails an address the caller picks
// (double opt-in confirmation) - same hazard class as the sign-in link.
// Per address: enough for "it did not arrive, send another", no more.
'newsletter_subscribe_email' => [
'policy' => 'sliding_window',
'limit' => 3,
'interval' => '15 minutes',
],
'newsletter_subscribe_ip' => [
'policy' => 'sliding_window',
'limit' => 20,
'interval' => '1 hour',
],
],
],
]);
9 changes: 8 additions & 1 deletion config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
$parameters->set('stripeWebhookSecret', '%env(STRIPE_WEBHOOK_SECRET)%');
$parameters->set('bounceEmailDomain', '%env(BOUNCE_EMAIL_DOMAIN)%');

$parameters->set('listmonkApiUrl', '%env(trim:string:LISTMONK_API_URL)%');
$parameters->set('listmonkApiUser', '%env(trim:string:LISTMONK_API_USER)%');
$parameters->set('listmonkApiToken', '%env(trim:string:LISTMONK_API_TOKEN)%');

$parameters->set('auth0Domain', '%env(trim:string:AUTH0_DOMAIN)%');
$parameters->set('auth0ClientId', '%env(trim:string:AUTH0_CLIENT_ID)%');
$parameters->set('auth0ClientSecret', '%env(trim:string:AUTH0_CLIENT_SECRET)%');
Expand Down Expand Up @@ -77,7 +81,10 @@
->bind('$auth0TrickleLoginEnabled', '%auth0TrickleLoginEnabled%')
->bind('$nativeLoginEnabled', '%nativeLoginEnabled%')
->bind('$nativeRegistrationEnabled', '%nativeRegistrationEnabled%')
->bind('$signInLinkLifetimeSeconds', '%signInLinkLifetimeSeconds%');
->bind('$signInLinkLifetimeSeconds', '%signInLinkLifetimeSeconds%')
->bind('$listmonkApiUrl', '%listmonkApiUrl%')
->bind('$listmonkApiUser', '%listmonkApiUser%')
->bind('$listmonkApiToken', '%listmonkApiToken%');

$services->set(PdoSessionHandler::class)
->args([
Expand Down
64 changes: 64 additions & 0 deletions docs/features/newsletter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Newsletter (Listmonk integration)

Newsletters are sent from a self-hosted [Listmonk](https://listmonk.app) (production: `https://listmonk.myspeedpuzzling.com`). **MySpeedPuzzling is the source of truth for who is subscribed; Listmonk is a mirror + send engine.**

## Audience model

| Who | Where subscription lives | How they subscribe |
|---|---|---|
| Registered players | `Player::$newsletterEnabled` (opt-out, default `true`) | Profile → Messaging & notifications |
| Guests (no account) | `NewsletterSubscriber` entity (`pending` → `confirmed` → `unsubscribed`) | Footer form on every page, **double opt-in** (confirmation e-mail, 48h token) |

One e-mail = one recipient. When an address belongs to both a player and a guest row, the player wins (`GetNewsletterRecipients`).

## Listmonk structure

- **6 private, single opt-in lists**, one per locale: `Newsletter EN/CS/DE/ES/FR/JA` (`ListmonkNewsletterLists`). Looked up by name, auto-created when missing — no ids configured anywhere.
- Subscriber attribs maintained by the sync: `locale`, `audience` (`player`/`guest`), `unsubscribe_url` (per-recipient signed MySpeedPuzzling URL), `manage_url` (players only).
- The campaign template (versioned at [`listmonk-campaign-template.html`](listmonk-campaign-template.html), uploaded to Listmonk as **"MySpeedPuzzling Newsletter"**) renders a localized footer from those attribs. After editing the file, re-upload via `PUT /api/templates/{id}`.

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

`SyncNewsletterSubscribersHandler` reconciles both directions in one run:

- **Pull**: memberships unsubscribed in Listmonk (RFC 8058 one-click List-Unsubscribe header, archive page) → `newsletterEnabled=false` / guest `unsubscribed` in MySpeedPuzzling.
- **Push**: create missing subscribers (bulk CSV import above 50, e.g. the initial ~10k import), update drifted ones (name/locale list/attribs), mark MySpeedPuzzling unsubscribes as `unsubscribed` in Listmonk (kept as suppression rows, not deleted), delete subscribers whose e-mail no longer exists here at all (**deleted players are removed from Listmonk**).

Direction rules (enforced by `NewsletterSyncPlanner`, unit-tested):

- **An unsubscribe wins wherever it happened.**
- **The cron never flips a Listmonk unsubscribe back to confirmed.** Only explicit user actions do, via `PushNewsletterSubscriberToListmonk` (profile toggle re-enable, double opt-in confirm) — verified against Listmonk: `add`+`status=confirmed` flips an unsubscribed membership, plain PUT+preconfirm does not.
- Blocklisted subscribers (bounces) are never touched except player-deletion cleanup.
- Subscribers in non-newsletter lists are never fully deleted and foreign memberships are preserved.

Immediate pushes (async messages, cron is the safety net): profile newsletter toggle (`EditMessagingSettingsHandler`), opt-in confirm, unsubscribe page, and `DeletePlayerHandler` → `RemoveNewsletterSubscriberFromListmonk` (also wipes a guest row with the same address).

## Unsubscribe flow (email links)

Every newsletter footer links `attribs.unsubscribe_url` → `/{locale}/newsletter/unsubscribe/{token}`:

- Stateless HMAC token (`NewsletterTokenSigner`), bound to audience+id+e-mail, **no expiry** (old newsletters must keep working); dies automatically when the e-mail changes.
- The landing page changes nothing on GET (scanner-safe) and offers exactly two options: **one-click unsubscribe** (POST) and — for players — **manage notification settings** (edit profile).
- Listmonk additionally sends its own `List-Unsubscribe`/`List-Unsubscribe-Post` headers pointing at itself (Gmail/Yahoo one-click); those unsubscribes reach MySpeedPuzzling via the cron pull within 15 minutes.

## Public signup (footer)

- Guests: e-mail form → `POST newsletter_subscribe` (stateless CSRF `newsletter-subscribe` — the form is on every anonymous page, a session-backed token would kill shared caching; see `config/packages/csrf.php`), rate-limited per address (3/15min) and IP (20/h), → confirmation e-mail (`emails/newsletter_confirmation.html.twig`, transactional transport) → `newsletter_confirm` → confirmed + pushed to Listmonk.
- Logged-in players see a link to notification settings instead of the form.

## Configuration

```
LISTMONK_API_URL=http://listmonk:9000 # empty token disables the whole integration
LISTMONK_API_USER=api-dev # prod: api-msp-web via Infisical
LISTMONK_API_TOKEN=...
```

Dev: the `listmonk-seed` compose service seeds the `api-dev` API user; admin UI `localhost:8090` (admin/adminadmin). Dev Listmonk SMTP points at Mailpit via the DB `settings` table (the `LISTMONK_smtp__*`/config values are only install-time seeds — same as production, the live SMTP config is the DB row). Tests run with the integration disabled (`.env.test`).

## Sending a campaign (checklist)

1. Content per locale → one campaign per locale list, template "MySpeedPuzzling Newsletter", content type HTML.
2. Use `{{ if .Subscriber.FirstName }}` greeting guard (guests have no name); CTA via `<a class="button" href="...">`.
3. Test send to yourself first (`POST /api/campaigns/{id}/test` — payload must repeat the campaign fields incl. `messenger: "email"`).
4. Production send throttle is already configured conservatively (600/h); campaigns per locale can run simultaneously — the sliding window is global.
Loading