From e7f6d85bda92caa9a92da39eae19618664761627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Sun, 26 Jul 2026 12:09:15 +0200 Subject: [PATCH 1/5] Newsletter: Listmonk integration with double opt-in signup and two-way 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 Claude-Session: https://claude.ai/code/session_01GVCsN9ELLwdwNPEXpGUcnt --- .env | 9 + .env.test | 3 + CLAUDE.md | 1 + compose.yml | 20 +- config/packages/csrf.php | 6 + config/packages/messenger.php | 5 + config/packages/rate_limiter.php | 13 + config/services.php | 9 +- docs/features/newsletter/README.md | 64 +++ .../listmonk-campaign-template.html | 128 ++++++ migrations/Version20260726093054.php | 32 ++ ...yncNewsletterSubscribersConsoleCommand.php | 37 ++ .../NewsletterCheckInboxController.php | 31 ++ .../NewsletterConfirmController.php | 74 ++++ .../NewsletterSubscribeController.php | 94 +++++ ...NewsletterUnsubscribeConfirmController.php | 70 ++++ .../NewsletterUnsubscribeController.php | 54 +++ src/Entity/NewsletterSubscriber.php | 86 ++++ src/Exceptions/InvalidNewsletterToken.php | 9 + src/Exceptions/ListmonkRequestFailed.php | 9 + .../NewsletterConfirmTokenExpired.php | 9 + .../NewsletterSubscriberNotFound.php | 15 + src/Message/ConfirmNewsletterSubscription.php | 13 + .../PushNewsletterSubscriberToListmonk.php | 20 + ...RemoveNewsletterSubscriberFromListmonk.php | 18 + src/Message/SubscribeToNewsletter.php | 15 + src/Message/SyncNewsletterSubscribers.php | 9 + src/Message/UnsubscribeFromNewsletter.php | 13 + .../ConfirmNewsletterSubscriptionHandler.php | 70 ++++ src/MessageHandler/DeletePlayerHandler.php | 28 ++ .../EditMessagingSettingsHandler.php | 9 + ...hNewsletterSubscriberToListmonkHandler.php | 99 +++++ ...ewsletterSubscriberFromListmonkHandler.php | 48 +++ .../SubscribeToNewsletterHandler.php | 101 +++++ .../SyncNewsletterSubscribersHandler.php | 263 +++++++++++++ .../UnsubscribeFromNewsletterHandler.php | 67 ++++ src/Query/GetNewsletterRecipients.php | 115 ++++++ .../NewsletterSubscriberRepository.php | 52 +++ src/Results/NewsletterRecipient.php | 27 ++ src/Services/Listmonk/ListmonkClient.php | 367 ++++++++++++++++++ .../Listmonk/ListmonkNewsletterLists.php | 86 ++++ .../Listmonk/NewsletterAttributesBuilder.php | 59 +++ .../Listmonk/NewsletterSyncPlanner.php | 189 +++++++++ src/Services/NewsletterTokenSigner.php | 195 ++++++++++ src/Value/DesiredNewsletterSubscriber.php | 21 + src/Value/ListmonkSubscriber.php | 109 ++++++ src/Value/NewsletterAudience.php | 16 + src/Value/NewsletterConfirmClaim.php | 15 + src/Value/NewsletterSubscriberStatus.php | 12 + src/Value/NewsletterSyncDeletion.php | 17 + src/Value/NewsletterSyncListUnsubscribe.php | 15 + src/Value/NewsletterSyncPlan.php | 33 ++ src/Value/NewsletterSyncUpdate.php | 16 + src/Value/NewsletterUnsubscribeClaim.php | 15 + templates/base.html.twig | 24 ++ .../emails/newsletter_confirmation.html.twig | 39 ++ templates/newsletter/check-inbox.html.twig | 31 ++ templates/newsletter/confirmed.html.twig | 40 ++ templates/newsletter/invalid-token.html.twig | 27 ++ templates/newsletter/unsubscribe.html.twig | 34 ++ templates/newsletter/unsubscribed.html.twig | 35 ++ ...nfirmNewsletterSubscriptionHandlerTest.php | 98 +++++ .../SubscribeToNewsletterHandlerTest.php | 80 ++++ .../UnsubscribeFromNewsletterHandlerTest.php | 73 ++++ tests/Services/NewsletterSyncPlannerTest.php | 206 ++++++++++ tests/Services/NewsletterTokenSignerTest.php | 90 +++++ translations/emails.cs.yml | 9 + translations/emails.de.yml | 9 + translations/emails.en.yml | 9 + translations/emails.es.yml | 9 + translations/emails.fr.yml | 9 + translations/emails.ja.yml | 9 + translations/messages.cs.yml | 42 ++ translations/messages.de.yml | 42 ++ translations/messages.en.yml | 42 ++ translations/messages.es.yml | 42 ++ translations/messages.fr.yml | 42 ++ translations/messages.ja.yml | 42 ++ 78 files changed, 3990 insertions(+), 3 deletions(-) create mode 100644 docs/features/newsletter/README.md create mode 100644 docs/features/newsletter/listmonk-campaign-template.html create mode 100644 migrations/Version20260726093054.php create mode 100644 src/ConsoleCommands/SyncNewsletterSubscribersConsoleCommand.php create mode 100644 src/Controller/NewsletterCheckInboxController.php create mode 100644 src/Controller/NewsletterConfirmController.php create mode 100644 src/Controller/NewsletterSubscribeController.php create mode 100644 src/Controller/NewsletterUnsubscribeConfirmController.php create mode 100644 src/Controller/NewsletterUnsubscribeController.php create mode 100644 src/Entity/NewsletterSubscriber.php create mode 100644 src/Exceptions/InvalidNewsletterToken.php create mode 100644 src/Exceptions/ListmonkRequestFailed.php create mode 100644 src/Exceptions/NewsletterConfirmTokenExpired.php create mode 100644 src/Exceptions/NewsletterSubscriberNotFound.php create mode 100644 src/Message/ConfirmNewsletterSubscription.php create mode 100644 src/Message/PushNewsletterSubscriberToListmonk.php create mode 100644 src/Message/RemoveNewsletterSubscriberFromListmonk.php create mode 100644 src/Message/SubscribeToNewsletter.php create mode 100644 src/Message/SyncNewsletterSubscribers.php create mode 100644 src/Message/UnsubscribeFromNewsletter.php create mode 100644 src/MessageHandler/ConfirmNewsletterSubscriptionHandler.php create mode 100644 src/MessageHandler/PushNewsletterSubscriberToListmonkHandler.php create mode 100644 src/MessageHandler/RemoveNewsletterSubscriberFromListmonkHandler.php create mode 100644 src/MessageHandler/SubscribeToNewsletterHandler.php create mode 100644 src/MessageHandler/SyncNewsletterSubscribersHandler.php create mode 100644 src/MessageHandler/UnsubscribeFromNewsletterHandler.php create mode 100644 src/Query/GetNewsletterRecipients.php create mode 100644 src/Repository/NewsletterSubscriberRepository.php create mode 100644 src/Results/NewsletterRecipient.php create mode 100644 src/Services/Listmonk/ListmonkClient.php create mode 100644 src/Services/Listmonk/ListmonkNewsletterLists.php create mode 100644 src/Services/Listmonk/NewsletterAttributesBuilder.php create mode 100644 src/Services/Listmonk/NewsletterSyncPlanner.php create mode 100644 src/Services/NewsletterTokenSigner.php create mode 100644 src/Value/DesiredNewsletterSubscriber.php create mode 100644 src/Value/ListmonkSubscriber.php create mode 100644 src/Value/NewsletterAudience.php create mode 100644 src/Value/NewsletterConfirmClaim.php create mode 100644 src/Value/NewsletterSubscriberStatus.php create mode 100644 src/Value/NewsletterSyncDeletion.php create mode 100644 src/Value/NewsletterSyncListUnsubscribe.php create mode 100644 src/Value/NewsletterSyncPlan.php create mode 100644 src/Value/NewsletterSyncUpdate.php create mode 100644 src/Value/NewsletterUnsubscribeClaim.php create mode 100644 templates/emails/newsletter_confirmation.html.twig create mode 100644 templates/newsletter/check-inbox.html.twig create mode 100644 templates/newsletter/confirmed.html.twig create mode 100644 templates/newsletter/invalid-token.html.twig create mode 100644 templates/newsletter/unsubscribe.html.twig create mode 100644 templates/newsletter/unsubscribed.html.twig create mode 100644 tests/MessageHandler/ConfirmNewsletterSubscriptionHandlerTest.php create mode 100644 tests/MessageHandler/SubscribeToNewsletterHandlerTest.php create mode 100644 tests/MessageHandler/UnsubscribeFromNewsletterHandlerTest.php create mode 100644 tests/Services/NewsletterSyncPlannerTest.php create mode 100644 tests/Services/NewsletterTokenSignerTest.php diff --git a/.env b/.env index f74eb9f98..d8d43ac5b 100644 --- a/.env +++ b/.env @@ -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= diff --git a/.env.test b/.env.test index 37ada11b0..7ff644159 100644 --- a/.env.test +++ b/.env.test @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 63a858242..e9f3be7bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/compose.yml b/compose.yml index e25f3fe63..4706aa322 100644 --- a/compose.yml +++ b/compose.yml @@ -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 @@ -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 diff --git a/config/packages/csrf.php b/config/packages/csrf.php index 531789f2f..3b9450f1c 100644 --- a/config/packages/csrf.php +++ b/config/packages/csrf.php @@ -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', ], ], ], diff --git a/config/packages/messenger.php b/config/packages/messenger.php index 8c1ac47f3..63aa84ded 100644 --- a/config/packages/messenger.php +++ b/config/packages/messenger.php @@ -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([ @@ -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', diff --git a/config/packages/rate_limiter.php b/config/packages/rate_limiter.php index da2932be7..5058e1b92 100644 --- a/config/packages/rate_limiter.php +++ b/config/packages/rate_limiter.php @@ -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', + ], ], ], ]); diff --git a/config/services.php b/config/services.php index 2d456700a..5f890cf12 100644 --- a/config/services.php +++ b/config/services.php @@ -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)%'); @@ -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([ diff --git a/docs/features/newsletter/README.md b/docs/features/newsletter/README.md new file mode 100644 index 000000000..bf0e6434f --- /dev/null +++ b/docs/features/newsletter/README.md @@ -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 ``. +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. diff --git a/docs/features/newsletter/listmonk-campaign-template.html b/docs/features/newsletter/listmonk-campaign-template.html new file mode 100644 index 000000000..6d8b6295c --- /dev/null +++ b/docs/features/newsletter/listmonk-campaign-template.html @@ -0,0 +1,128 @@ + + + + + + + + MySpeedPuzzling + + + + + + + +
+ + + + + + + + + + + + + + + + +
+ + MySpeedPuzzling + MySpeedPuzzling.com + +
+
+ {{ template "content" . }} +
+
+ {{ TrackView }} +
+ + diff --git a/migrations/Version20260726093054.php b/migrations/Version20260726093054.php new file mode 100644 index 000000000..c513b9b44 --- /dev/null +++ b/migrations/Version20260726093054.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE newsletter_subscriber (status VARCHAR(255) DEFAULT \'pending\' NOT NULL, confirmed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, unsubscribed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, id UUID NOT NULL, email VARCHAR(255) NOT NULL, locale VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ip_address VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_401562C3E7927C74 ON newsletter_subscriber (email)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE newsletter_subscriber'); + } +} diff --git a/src/ConsoleCommands/SyncNewsletterSubscribersConsoleCommand.php b/src/ConsoleCommands/SyncNewsletterSubscribersConsoleCommand.php new file mode 100644 index 000000000..25b24fadf --- /dev/null +++ b/src/ConsoleCommands/SyncNewsletterSubscribersConsoleCommand.php @@ -0,0 +1,37 @@ +commandBus->dispatch(new SyncNewsletterSubscribers()); + + $io->success('Newsletter subscribers synced with Listmonk.'); + + return self::SUCCESS; + } +} diff --git a/src/Controller/NewsletterCheckInboxController.php b/src/Controller/NewsletterCheckInboxController.php new file mode 100644 index 000000000..e426b6fc2 --- /dev/null +++ b/src/Controller/NewsletterCheckInboxController.php @@ -0,0 +1,31 @@ + '/newsletter/zkontrolujte-schranku', + 'en' => '/en/newsletter/check-your-inbox', + 'es' => '/es/newsletter/revisa-tu-correo', + 'ja' => '/ja/newsletter/check-your-inbox', + 'fr' => '/fr/newsletter/verifiez-votre-boite', + 'de' => '/de/newsletter/postfach-pruefen', + ], + name: 'newsletter_check_inbox', + )] + public function __invoke(): Response + { + return $this->render('newsletter/check-inbox.html.twig', [ + 'expiresHours' => NewsletterTokenSigner::CONFIRM_LIFETIME_HOURS, + ]); + } +} diff --git a/src/Controller/NewsletterConfirmController.php b/src/Controller/NewsletterConfirmController.php new file mode 100644 index 000000000..4562ab26d --- /dev/null +++ b/src/Controller/NewsletterConfirmController.php @@ -0,0 +1,74 @@ + '/newsletter/potvrzeni/{token}', + 'en' => '/en/newsletter/confirm/{token}', + 'es' => '/es/newsletter/confirmar/{token}', + 'ja' => '/ja/newsletter/confirm/{token}', + 'fr' => '/fr/newsletter/confirmation/{token}', + 'de' => '/de/newsletter/bestaetigung/{token}', + ], + name: 'newsletter_confirm', + )] + public function __invoke(string $token): Response + { + try { + $claim = $this->tokenSigner->parseConfirmToken($token); + $this->messageBus->dispatch(new ConfirmNewsletterSubscription($token)); + } catch (NewsletterConfirmTokenExpired) { + return $this->renderInvalid('newsletter.invalid.expired_text'); + } catch (InvalidNewsletterToken) { + return $this->renderInvalid('newsletter.invalid.invalid_text'); + } catch (HandlerFailedException $exception) { + $previous = $exception->getPrevious(); + + if ($previous instanceof NewsletterConfirmTokenExpired) { + return $this->renderInvalid('newsletter.invalid.expired_text'); + } + + if ($previous instanceof InvalidNewsletterToken) { + return $this->renderInvalid('newsletter.invalid.invalid_text'); + } + + throw $exception; + } + + return $this->render('newsletter/confirmed.html.twig', [ + 'isPlayer' => $claim->audience === NewsletterAudience::Player, + ]); + } + + private function renderInvalid(string $messageKey): Response + { + return $this->render('newsletter/invalid-token.html.twig', [ + 'messageKey' => $messageKey, + ], new Response(status: Response::HTTP_NOT_FOUND)); + } +} diff --git a/src/Controller/NewsletterSubscribeController.php b/src/Controller/NewsletterSubscribeController.php new file mode 100644 index 000000000..4e8fe9132 --- /dev/null +++ b/src/Controller/NewsletterSubscribeController.php @@ -0,0 +1,94 @@ + '/newsletter/prihlasit-odber', + 'en' => '/en/newsletter/subscribe', + 'es' => '/es/newsletter/suscribirse', + 'ja' => '/ja/newsletter/subscribe', + 'fr' => '/fr/newsletter/inscription', + 'de' => '/de/newsletter/anmelden', + ], + name: 'newsletter_subscribe', + methods: ['POST'], + )] + public function __invoke(Request $request): Response + { + if (!$this->isCsrfTokenValid(self::CSRF_TOKEN_ID, (string) $request->request->get('_token'))) { + $this->addFlash('warning', $this->translator->trans('newsletter.flash.try_again')); + + return $this->redirectBack($request); + } + + $email = trim((string) $request->request->get('email')); + + $violations = $this->validator->validate($email, [new NotBlank(), new Email()]); + + if (count($violations) > 0) { + $this->addFlash('warning', $this->translator->trans('newsletter.flash.invalid_email')); + + return $this->redirectBack($request); + } + + $emailLimit = $this->newsletterSubscribeEmailLimiter->create(mb_strtolower($email))->consume(); + $ipLimit = $this->newsletterSubscribeIpLimiter->create($request->getClientIp() ?? 'unknown')->consume(); + + if ($emailLimit->isAccepted() === false || $ipLimit->isAccepted() === false) { + $this->addFlash('warning', $this->translator->trans('newsletter.flash.too_many_attempts')); + + return $this->redirectBack($request); + } + + $this->messageBus->dispatch(new SubscribeToNewsletter( + email: $email, + locale: $request->getLocale(), + ipAddress: $request->getClientIp(), + )); + + return $this->redirectToRoute('newsletter_check_inbox'); + } + + private function redirectBack(Request $request): RedirectResponse + { + $referer = $request->headers->get('referer'); + + if ($referer !== null && str_starts_with($referer, $request->getSchemeAndHttpHost() . '/')) { + return $this->redirect($referer); + } + + return $this->redirectToRoute('homepage'); + } +} diff --git a/src/Controller/NewsletterUnsubscribeConfirmController.php b/src/Controller/NewsletterUnsubscribeConfirmController.php new file mode 100644 index 000000000..ad136bf32 --- /dev/null +++ b/src/Controller/NewsletterUnsubscribeConfirmController.php @@ -0,0 +1,70 @@ + '/newsletter/odhlasit/{token}/potvrdit', + 'en' => '/en/newsletter/unsubscribe/{token}/confirm', + 'es' => '/es/newsletter/cancelar/{token}/confirmar', + 'ja' => '/ja/newsletter/unsubscribe/{token}/confirm', + 'fr' => '/fr/newsletter/desabonnement/{token}/confirmer', + 'de' => '/de/newsletter/abmelden/{token}/bestaetigen', + ], + name: 'newsletter_unsubscribe_confirm', + methods: ['POST'], + )] + public function __invoke(string $token, Request $request): Response + { + if (!$this->isCsrfTokenValid(self::CSRF_TOKEN_ID, (string) $request->request->get('_token'))) { + return $this->redirectToRoute('newsletter_unsubscribe', ['token' => $token]); + } + + try { + $claim = $this->tokenSigner->parseUnsubscribeToken($token); + $this->messageBus->dispatch(new UnsubscribeFromNewsletter($token)); + } catch (InvalidNewsletterToken) { + return $this->renderInvalid(); + } catch (HandlerFailedException $exception) { + if ($exception->getPrevious() instanceof InvalidNewsletterToken) { + return $this->renderInvalid(); + } + + throw $exception; + } + + return $this->render('newsletter/unsubscribed.html.twig', [ + 'isPlayer' => $claim->audience === NewsletterAudience::Player, + ]); + } + + private function renderInvalid(): Response + { + return $this->render('newsletter/invalid-token.html.twig', [ + 'messageKey' => 'newsletter.invalid.invalid_text', + ], new Response(status: Response::HTTP_NOT_FOUND)); + } +} diff --git a/src/Controller/NewsletterUnsubscribeController.php b/src/Controller/NewsletterUnsubscribeController.php new file mode 100644 index 000000000..c9f14c129 --- /dev/null +++ b/src/Controller/NewsletterUnsubscribeController.php @@ -0,0 +1,54 @@ + '/newsletter/odhlasit/{token}', + 'en' => '/en/newsletter/unsubscribe/{token}', + 'es' => '/es/newsletter/cancelar/{token}', + 'ja' => '/ja/newsletter/unsubscribe/{token}', + 'fr' => '/fr/newsletter/desabonnement/{token}', + 'de' => '/de/newsletter/abmelden/{token}', + ], + name: 'newsletter_unsubscribe', + )] + public function __invoke(string $token): Response + { + try { + $claim = $this->tokenSigner->parseUnsubscribeToken($token); + } catch (InvalidNewsletterToken) { + return $this->render('newsletter/invalid-token.html.twig', [ + 'messageKey' => 'newsletter.invalid.invalid_text', + ], new Response(status: Response::HTTP_NOT_FOUND)); + } + + return $this->render('newsletter/unsubscribe.html.twig', [ + 'token' => $token, + 'email' => $claim->email, + 'isPlayer' => $claim->audience === NewsletterAudience::Player, + ]); + } +} diff --git a/src/Entity/NewsletterSubscriber.php b/src/Entity/NewsletterSubscriber.php new file mode 100644 index 000000000..96a875ee8 --- /dev/null +++ b/src/Entity/NewsletterSubscriber.php @@ -0,0 +1,86 @@ + 'pending'])] + public NewsletterSubscriberStatus $status = NewsletterSubscriberStatus::Pending; + + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + public null|DateTimeImmutable $confirmedAt = null; + + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + public null|DateTimeImmutable $unsubscribedAt = null; + + public function __construct( + #[Id] + #[Immutable] + #[Column(type: UuidType::NAME, unique: true)] + public UuidInterface $id, + /** Always stored lowercased and trimmed */ + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(unique: true)] + public string $email, + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column] + public string $locale, + #[Immutable] + #[Column(type: Types::DATETIME_IMMUTABLE)] + public DateTimeImmutable $createdAt, + /** Consent audit trail for the double opt-in request */ + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(nullable: true)] + public null|string $ipAddress, + ) { + } + + public function confirm(DateTimeImmutable $now): void + { + $this->status = NewsletterSubscriberStatus::Confirmed; + $this->confirmedAt = $now; + } + + public function unsubscribe(DateTimeImmutable $now): void + { + $this->status = NewsletterSubscriberStatus::Unsubscribed; + $this->unsubscribedAt = $now; + } + + /** + * A repeated signup from the public form: refresh the locale and consent + * context and require a fresh opt-in confirmation, but never downgrade an + * already confirmed subscription. + */ + public function startNewOptIn(string $locale, null|string $ipAddress): void + { + $this->locale = $locale; + $this->ipAddress = $ipAddress; + + if ($this->status !== NewsletterSubscriberStatus::Confirmed) { + $this->status = NewsletterSubscriberStatus::Pending; + } + } +} diff --git a/src/Exceptions/InvalidNewsletterToken.php b/src/Exceptions/InvalidNewsletterToken.php new file mode 100644 index 000000000..edc1390d7 --- /dev/null +++ b/src/Exceptions/InvalidNewsletterToken.php @@ -0,0 +1,9 @@ +tokenSigner->parseConfirmToken($message->token); + + if ($claim->audience === NewsletterAudience::Player) { + try { + $player = $this->playerRepository->get($claim->id); + } catch (PlayerNotFound) { + throw new InvalidNewsletterToken(); + } + + // The link dies the moment the account e-mail changes + if ($player->email === null || mb_strtolower(trim($player->email)) !== $claim->email) { + throw new InvalidNewsletterToken(); + } + + $player->changeNewsletterEnabled(true); + } else { + try { + $subscriber = $this->newsletterSubscriberRepository->get($claim->id); + } catch (NewsletterSubscriberNotFound) { + throw new InvalidNewsletterToken(); + } + + if ($subscriber->email !== $claim->email) { + throw new InvalidNewsletterToken(); + } + + $subscriber->confirm($this->clock->now()); + } + + $this->messageBus->dispatch(new PushNewsletterSubscriberToListmonk($claim->email)); + } +} diff --git a/src/MessageHandler/DeletePlayerHandler.php b/src/MessageHandler/DeletePlayerHandler.php index bfece4372..1e79aa658 100644 --- a/src/MessageHandler/DeletePlayerHandler.php +++ b/src/MessageHandler/DeletePlayerHandler.php @@ -34,12 +34,15 @@ use SpeedPuzzling\Web\Entity\WishListItem; use SpeedPuzzling\Web\Exceptions\PlayerNotFound; use SpeedPuzzling\Web\Message\DeletePlayer; +use SpeedPuzzling\Web\Message\RemoveNewsletterSubscriberFromListmonk; +use SpeedPuzzling\Web\Repository\NewsletterSubscriberRepository; use SpeedPuzzling\Web\Repository\PlayerRepository; use SpeedPuzzling\Web\Value\Puzzler; use SpeedPuzzling\Web\Value\PuzzlersGroup; use Stripe\Exception\ApiErrorException; use Stripe\StripeClient; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\MessageBusInterface; #[AsMessageHandler] final class DeletePlayerHandler @@ -49,6 +52,8 @@ public function __construct( private readonly PlayerRepository $playerRepository, private readonly StripeClient $stripeClient, private readonly LoggerInterface $logger, + private readonly NewsletterSubscriberRepository $newsletterSubscriberRepository, + private readonly MessageBusInterface $messageBus, ) { } @@ -78,6 +83,7 @@ public function __invoke(DeletePlayer $message): void $this->anonymizeCompetitionSeries($playerId); $this->anonymizeBulkSimpleFks($playerId); $this->hashEmailAuditLog($player); + $this->cleanupNewsletter($player); $membership = $this->entityManager->getRepository(Membership::class)->findOneBy(['player' => $player]); @@ -382,6 +388,28 @@ private function anonymizeBulkSimpleFks(string $playerId): void $conn->executeStatement('UPDATE oauth2_client_request SET reviewed_by_id = NULL WHERE reviewed_by_id = :p', ['p' => $playerId]); } + /** + * The player row (and with it the e-mail) is gone after this transaction, + * so the Listmonk removal must be queued now, carrying the address. A guest + * newsletter subscription under the same address is wiped too. + */ + private function cleanupNewsletter(Player $player): void + { + if ($player->email === null) { + return; + } + + $email = mb_strtolower(trim($player->email)); + + $guestSubscriber = $this->newsletterSubscriberRepository->findByEmail($email); + + if ($guestSubscriber !== null) { + $this->newsletterSubscriberRepository->remove($guestSubscriber); + } + + $this->messageBus->dispatch(new RemoveNewsletterSubscriberFromListmonk($email)); + } + private function hashEmailAuditLog(Player $player): void { if ($player->email === null) { diff --git a/src/MessageHandler/EditMessagingSettingsHandler.php b/src/MessageHandler/EditMessagingSettingsHandler.php index aa324f639..724a39bf4 100644 --- a/src/MessageHandler/EditMessagingSettingsHandler.php +++ b/src/MessageHandler/EditMessagingSettingsHandler.php @@ -6,14 +6,17 @@ use SpeedPuzzling\Web\Exceptions\PlayerNotFound; use SpeedPuzzling\Web\Message\EditMessagingSettings; +use SpeedPuzzling\Web\Message\PushNewsletterSubscriberToListmonk; use SpeedPuzzling\Web\Repository\PlayerRepository; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\MessageBusInterface; #[AsMessageHandler] readonly final class EditMessagingSettingsHandler { public function __construct( private PlayerRepository $playerRepository, + private MessageBusInterface $messageBus, ) { } @@ -24,9 +27,15 @@ public function __invoke(EditMessagingSettings $message): void { $player = $this->playerRepository->get($message->playerId); + $newsletterChanged = $player->newsletterEnabled !== $message->newsletterEnabled; + $player->changeAllowDirectMessages($message->allowDirectMessages); $player->changeEmailNotificationsEnabled($message->emailNotificationsEnabled); $player->changeEmailNotificationFrequency($message->emailNotificationFrequency); $player->changeNewsletterEnabled($message->newsletterEnabled); + + if ($newsletterChanged && $player->email !== null) { + $this->messageBus->dispatch(new PushNewsletterSubscriberToListmonk($player->email)); + } } } diff --git a/src/MessageHandler/PushNewsletterSubscriberToListmonkHandler.php b/src/MessageHandler/PushNewsletterSubscriberToListmonkHandler.php new file mode 100644 index 000000000..f85009205 --- /dev/null +++ b/src/MessageHandler/PushNewsletterSubscriberToListmonkHandler.php @@ -0,0 +1,99 @@ +listmonkClient->isEnabled() === false) { + return; + } + + $email = mb_strtolower(trim($message->email)); + + if ($email === '') { + return; + } + + $recipient = $this->getNewsletterRecipients->byEmail($email); + + $existingData = $this->listmonkClient->findSubscriberByEmail($email); + $existing = $existingData === null ? null : ListmonkSubscriber::fromApi($existingData); + + $listIdByLocale = $this->newsletterLists->ensureListsExist(); + $newsletterListIds = array_values($listIdByLocale); + + if ($recipient === null) { + // Nothing on our side (pending signup or vanished row): only clean up + // newsletter-list memberships, never touch foreign lists + if ($existing !== null && $existing->newsletterListIds($newsletterListIds) !== []) { + if ($existing->foreignListIds($newsletterListIds) === []) { + $this->listmonkClient->deleteSubscriber($existing->id); + } else { + $this->listmonkClient->removeFromLists([$existing->id], $existing->newsletterListIds($newsletterListIds)); + } + } + + return; + } + + if ($existing !== null && $existing->isBlocklisted()) { + $this->logger->info('Skipping Listmonk push for blocklisted subscriber', ['email_hash' => hash('sha256', $email)]); + + return; + } + + if ($recipient->subscribed) { + $locale = ListmonkNewsletterLists::normalizeLocale($recipient->locale); + $targetListId = $listIdByLocale[$locale] ?? $listIdByLocale[ListmonkNewsletterLists::DEFAULT_LOCALE]; + $attributes = $this->attributesBuilder->build($recipient); + + if ($existing === null) { + $this->listmonkClient->createSubscriber($email, $recipient->name, [$targetListId], $attributes); + + return; + } + + $targetListIds = [...$existing->foreignListIds($newsletterListIds), $targetListId]; + + $this->listmonkClient->updateSubscriber($existing->id, $email, $recipient->name, $targetListIds, $attributes); + // Explicit re-subscribe: force the membership back to confirmed even + // when it was previously unsubscribed + $this->listmonkClient->confirmListSubscriptions([$existing->id], [$targetListId]); + + return; + } + + if ($existing !== null) { + $this->listmonkClient->unsubscribeFromLists([$existing->id], $newsletterListIds); + } + } +} diff --git a/src/MessageHandler/RemoveNewsletterSubscriberFromListmonkHandler.php b/src/MessageHandler/RemoveNewsletterSubscriberFromListmonkHandler.php new file mode 100644 index 000000000..1ce4c9fba --- /dev/null +++ b/src/MessageHandler/RemoveNewsletterSubscriberFromListmonkHandler.php @@ -0,0 +1,48 @@ +listmonkClient->isEnabled() === false) { + return; + } + + $email = mb_strtolower(trim($message->email)); + + if ($email === '') { + return; + } + + $existingData = $this->listmonkClient->findSubscriberByEmail($email); + $existing = $existingData === null ? null : ListmonkSubscriber::fromApi($existingData); + + if ($existing === null) { + return; + } + + // GDPR deletion: remove the subscriber entirely, campaign history included + $this->listmonkClient->deleteSubscriber($existing->id); + + $this->logger->info('Deleted subscriber from Listmonk after player deletion', [ + 'listmonk_subscriber_id' => $existing->id, + ]); + } +} diff --git a/src/MessageHandler/SubscribeToNewsletterHandler.php b/src/MessageHandler/SubscribeToNewsletterHandler.php new file mode 100644 index 000000000..8fc3b7145 --- /dev/null +++ b/src/MessageHandler/SubscribeToNewsletterHandler.php @@ -0,0 +1,101 @@ +email)); + + if ($email === '') { + return; + } + + $locale = ListmonkNewsletterLists::normalizeLocale($message->locale); + + $recipient = $this->getNewsletterRecipients->byEmail($email); + + if ($recipient !== null && $recipient->audience === NewsletterAudience::Player) { + $audience = NewsletterAudience::Player; + $targetId = $recipient->id; + } else { + $subscriber = $this->newsletterSubscriberRepository->findByEmail($email); + + if ($subscriber === null) { + $subscriber = new NewsletterSubscriber( + id: Uuid::uuid7(), + email: $email, + locale: $locale, + createdAt: $this->clock->now(), + ipAddress: $message->ipAddress, + ); + + $this->newsletterSubscriberRepository->save($subscriber); + } else { + $subscriber->startNewOptIn($locale, $message->ipAddress); + } + + $audience = NewsletterAudience::Guest; + $targetId = $subscriber->id->toString(); + } + + $token = $this->tokenSigner->generateConfirmToken($audience, $targetId, $email); + + $confirmUrl = $this->urlGenerator->generate( + 'newsletter_confirm', + ['_locale' => $locale, 'token' => $token], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + $confirmationEmail = (new TemplatedEmail()) + ->to($email) + ->locale($locale) + ->subject($this->translator->trans('newsletter_confirmation.subject', domain: 'emails', locale: $locale)) + ->htmlTemplate('emails/newsletter_confirmation.html.twig') + ->context([ + 'confirmUrl' => $confirmUrl, + 'expiresHours' => NewsletterTokenSigner::CONFIRM_LIFETIME_HOURS, + ]); + + $confirmationEmail->getHeaders()->addTextHeader('X-Transport', 'transactional'); + + $this->mailer->send($confirmationEmail); + } +} diff --git a/src/MessageHandler/SyncNewsletterSubscribersHandler.php b/src/MessageHandler/SyncNewsletterSubscribersHandler.php new file mode 100644 index 000000000..7aefef4d1 --- /dev/null +++ b/src/MessageHandler/SyncNewsletterSubscribersHandler.php @@ -0,0 +1,263 @@ +listmonkClient->isEnabled() === false) { + $this->logger->info('Listmonk integration is disabled, skipping newsletter sync'); + + return; + } + + $listIdByLocale = $this->newsletterLists->ensureListsExist(); + + $actual = $this->fetchAllListmonkSubscribers(); + + $desired = []; + + foreach ($this->getNewsletterRecipients->all() as $recipient) { + $desired[] = new DesiredNewsletterSubscriber($recipient, $this->attributesBuilder->build($recipient)); + } + + $plan = $this->planner->plan($desired, $actual, $listIdByLocale); + + $this->applyPullUnsubscribes($plan->pullUnsubscribes); + $this->applyCreates($plan->creates, $listIdByLocale); + + foreach ($plan->updates as $update) { + $this->listmonkClient->updateSubscriber( + $update->listmonkId, + $update->desired->recipient->email, + $update->desired->recipient->name, + $update->targetListIds, + $update->desired->attributes, + ); + } + + foreach ($plan->listUnsubscribes as $listUnsubscribe) { + $this->listmonkClient->unsubscribeFromLists([$listUnsubscribe->listmonkId], $listUnsubscribe->listIds); + } + + foreach ($plan->deletions as $deletion) { + if ($deletion->fullDelete) { + $this->listmonkClient->deleteSubscriber($deletion->listmonkId); + } else { + $this->listmonkClient->removeFromLists([$deletion->listmonkId], $deletion->removeFromListIds); + } + } + + $this->logger->info('Newsletter Listmonk sync finished', [ + 'desired_recipients' => count($desired), + 'listmonk_subscribers' => count($actual), + 'pulled_unsubscribes' => count($plan->pullUnsubscribes), + 'created' => count($plan->creates), + 'updated' => count($plan->updates), + 'unsubscribed_in_listmonk' => count($plan->listUnsubscribes), + 'deleted' => count($plan->deletions), + ]); + } + + /** + * @return list + * + * @throws ListmonkRequestFailed + */ + private function fetchAllListmonkSubscribers(): array + { + $subscribers = []; + $page = 1; + + do { + $result = $this->listmonkClient->getSubscribersPage($page, self::SUBSCRIBERS_PER_PAGE); + + foreach ($result['results'] as $data) { + $subscriber = ListmonkSubscriber::fromApi($data); + + if ($subscriber !== null) { + $subscribers[] = $subscriber; + } + } + + $fetchedEverything = count($result['results']) < self::SUBSCRIBERS_PER_PAGE + || count($subscribers) >= $result['total']; + + $page++; + } while ($fetchedEverything === false && $page <= self::MAX_PAGES); + + return $subscribers; + } + + /** + * @param list<\SpeedPuzzling\Web\Results\NewsletterRecipient> $pullUnsubscribes + */ + private function applyPullUnsubscribes(array $pullUnsubscribes): void + { + foreach ($pullUnsubscribes as $recipient) { + try { + if ($recipient->audience === NewsletterAudience::Player) { + $this->playerRepository->get($recipient->id)->changeNewsletterEnabled(false); + } else { + $this->newsletterSubscriberRepository->get($recipient->id)->unsubscribe($this->clock->now()); + } + } catch (PlayerNotFound | NewsletterSubscriberNotFound) { + // Deleted between querying and applying - next run cleans Listmonk up + } + } + } + + /** + * @param list $creates + * @param array $listIdByLocale + * + * @throws ListmonkRequestFailed + */ + private function applyCreates(array $creates, array $listIdByLocale): void + { + if (count($creates) > self::BULK_IMPORT_THRESHOLD) { + $this->bulkImport($creates, $listIdByLocale); + + return; + } + + foreach ($creates as $create) { + $targetListId = $this->planner->targetListId($listIdByLocale, $create->recipient->locale); + + $this->listmonkClient->createSubscriber( + $create->recipient->email, + $create->recipient->name, + [$targetListId], + $create->attributes, + ); + } + } + + /** + * @param list $creates + * @param array $listIdByLocale + * + * @throws ListmonkRequestFailed + */ + private function bulkImport(array $creates, array $listIdByLocale): void + { + $createsByListId = []; + + foreach ($creates as $create) { + $createsByListId[$this->planner->targetListId($listIdByLocale, $create->recipient->locale)][] = $create; + } + + foreach ($createsByListId as $listId => $listCreates) { + $rows = ['email,name,attributes']; + + foreach ($listCreates as $create) { + $rows[] = implode(',', [ + self::csvField($create->recipient->email), + self::csvField($create->recipient->name), + self::csvField(json_encode($create->attributes, JSON_THROW_ON_ERROR)), + ]); + } + + $this->logger->info('Bulk-importing newsletter subscribers into Listmonk', [ + 'list_id' => $listId, + 'subscribers' => count($listCreates), + ]); + + $this->listmonkClient->importSubscribers(implode("\n", $rows), [$listId], markConfirmed: true); + $this->waitForImportToFinish(); + } + } + + /** + * @throws ListmonkRequestFailed + */ + private function waitForImportToFinish(): void + { + $waited = 0; + + while ($waited < self::IMPORT_TIMEOUT_SECONDS) { + $status = $this->listmonkClient->getImportStatus(); + $state = $status['status'] ?? null; + + if ($state === 'finished') { + $this->listmonkClient->stopImport(); + + return; + } + + if ($state === 'failed' || $state === 'stopped') { + $this->listmonkClient->stopImport(); + + throw new ListmonkRequestFailed('Listmonk subscriber import failed'); + } + + sleep(1); + $waited++; + } + + $this->listmonkClient->stopImport(); + + throw new ListmonkRequestFailed('Listmonk subscriber import timed out'); + } + + private static function csvField(string $value): string + { + return '"' . str_replace('"', '""', $value) . '"'; + } +} diff --git a/src/MessageHandler/UnsubscribeFromNewsletterHandler.php b/src/MessageHandler/UnsubscribeFromNewsletterHandler.php new file mode 100644 index 000000000..cc7a3da4d --- /dev/null +++ b/src/MessageHandler/UnsubscribeFromNewsletterHandler.php @@ -0,0 +1,67 @@ +tokenSigner->parseUnsubscribeToken($message->token); + + if ($claim->audience === NewsletterAudience::Player) { + try { + $player = $this->playerRepository->get($claim->id); + } catch (PlayerNotFound) { + throw new InvalidNewsletterToken(); + } + + if ($player->email === null || mb_strtolower(trim($player->email)) !== $claim->email) { + throw new InvalidNewsletterToken(); + } + + $player->changeNewsletterEnabled(false); + } else { + try { + $subscriber = $this->newsletterSubscriberRepository->get($claim->id); + } catch (NewsletterSubscriberNotFound) { + throw new InvalidNewsletterToken(); + } + + if ($subscriber->email !== $claim->email) { + throw new InvalidNewsletterToken(); + } + + $subscriber->unsubscribe($this->clock->now()); + } + + $this->messageBus->dispatch(new PushNewsletterSubscriberToListmonk($claim->email)); + } +} diff --git a/src/Query/GetNewsletterRecipients.php b/src/Query/GetNewsletterRecipients.php new file mode 100644 index 000000000..4c1734652 --- /dev/null +++ b/src/Query/GetNewsletterRecipients.php @@ -0,0 +1,115 @@ + + */ + public function all(): array + { + return array_values($this->fetch(email: null)); + } + + public function byEmail(string $email): null|NewsletterRecipient + { + $normalized = mb_strtolower(trim($email)); + + if ($normalized === '') { + return null; + } + + $recipients = $this->fetch(email: $normalized); + + return $recipients[$normalized] ?? null; + } + + /** + * @return array keyed by e-mail + */ + private function fetch(null|string $email): array + { + $playersQuery = << NewsletterSubscriberStatus::Pending->value]; + + if ($email !== null) { + $playersQuery .= ' AND LOWER(TRIM(email)) = :email'; + $guestsQuery .= ' AND email = :email'; + $parameters['email'] = $email; + } + + $recipients = []; + + /** @var array $playerRows */ + $playerRows = $this->database->executeQuery($playersQuery, array_intersect_key($parameters, ['email' => true]))->fetchAllAssociative(); + + foreach ($playerRows as $row) { + $recipient = new NewsletterRecipient( + audience: NewsletterAudience::Player, + id: $row['id'], + email: $row['email'], + name: $row['name'] ?? '', + locale: $row['locale'], + subscribed: $row['newsletter_enabled'], + ); + + $existing = $recipients[$recipient->email] ?? null; + + // Duplicate player e-mails: prefer the subscribed one + if ($existing === null || ($existing->subscribed === false && $recipient->subscribed === true)) { + $recipients[$recipient->email] = $recipient; + } + } + + /** @var array $guestRows */ + $guestRows = $this->database->executeQuery($guestsQuery, $parameters)->fetchAllAssociative(); + + foreach ($guestRows as $row) { + if (isset($recipients[$row['email']])) { + continue; + } + + $recipients[$row['email']] = new NewsletterRecipient( + audience: NewsletterAudience::Guest, + id: $row['id'], + email: $row['email'], + name: '', + locale: $row['locale'], + subscribed: $row['status'] === NewsletterSubscriberStatus::Confirmed->value, + ); + } + + return $recipients; + } +} diff --git a/src/Repository/NewsletterSubscriberRepository.php b/src/Repository/NewsletterSubscriberRepository.php new file mode 100644 index 000000000..69fbf6cfe --- /dev/null +++ b/src/Repository/NewsletterSubscriberRepository.php @@ -0,0 +1,52 @@ +entityManager->find(NewsletterSubscriber::class, $subscriberId); + + if ($subscriber === null) { + throw new NewsletterSubscriberNotFound(); + } + + return $subscriber; + } + + public function findByEmail(string $email): null|NewsletterSubscriber + { + return $this->entityManager->getRepository(NewsletterSubscriber::class) + ->findOneBy(['email' => mb_strtolower(trim($email))]); + } + + public function save(NewsletterSubscriber $subscriber): void + { + $this->entityManager->persist($subscriber); + } + + public function remove(NewsletterSubscriber $subscriber): void + { + $this->entityManager->remove($subscriber); + } +} diff --git a/src/Results/NewsletterRecipient.php b/src/Results/NewsletterRecipient.php new file mode 100644 index 000000000..ef8412c56 --- /dev/null +++ b/src/Results/NewsletterRecipient.php @@ -0,0 +1,27 @@ +listmonkApiUrl !== '' && $this->listmonkApiUser !== '' && $this->listmonkApiToken !== ''; + } + + /** + * @return list> + * + * @throws ListmonkRequestFailed + */ + public function getLists(): array + { + $data = $this->request('GET', '/api/lists', ['query' => ['per_page' => 'all']]); + $results = $this->extractData($data)['results'] ?? null; + + return $this->listOfArrays($results); + } + + /** + * @param list $tags + * @return array + * + * @throws ListmonkRequestFailed + */ + public function createList(string $name, string $type, string $optin, array $tags): array + { + $data = $this->request('POST', '/api/lists', [ + 'json' => [ + 'name' => $name, + 'type' => $type, + 'optin' => $optin, + 'tags' => $tags, + ], + ]); + + return $this->extractData($data); + } + + /** + * @return array{results: list>, total: int} + * + * @throws ListmonkRequestFailed + */ + public function getSubscribersPage(int $page, int $perPage): array + { + $data = $this->request('GET', '/api/subscribers', [ + 'query' => [ + 'page' => (string) $page, + 'per_page' => (string) $perPage, + 'order_by' => 'id', + 'order' => 'ASC', + ], + ]); + + $payload = $this->extractData($data); + $total = $payload['total'] ?? 0; + + return [ + 'results' => $this->listOfArrays($payload['results'] ?? null), + 'total' => is_numeric($total) ? (int) $total : 0, + ]; + } + + /** + * @return null|array + * + * @throws ListmonkRequestFailed + */ + public function findSubscriberByEmail(string $email): null|array + { + $escaped = str_replace("'", "''", mb_strtolower(trim($email))); + + $data = $this->request('GET', '/api/subscribers', [ + 'query' => [ + 'query' => sprintf("LOWER(subscribers.email) = '%s'", $escaped), + 'page' => '1', + 'per_page' => '1', + ], + ]); + + $results = $this->listOfArrays($this->extractData($data)['results'] ?? null); + + return $results[0] ?? null; + } + + /** + * @param list $listIds + * @param array $attribs + * @return array + * + * @throws ListmonkRequestFailed + */ + public function createSubscriber(string $email, string $name, array $listIds, array $attribs): array + { + $data = $this->request('POST', '/api/subscribers', [ + 'json' => [ + 'email' => $email, + 'name' => $name, + 'status' => 'enabled', + 'lists' => $listIds, + 'attribs' => (object) $attribs, + 'preconfirm_subscriptions' => true, + ], + ]); + + return $this->extractData($data); + } + + /** + * @param list $listIds + * @param array $attribs + * + * @throws ListmonkRequestFailed + */ + public function updateSubscriber(int $subscriberId, string $email, string $name, array $listIds, array $attribs): void + { + $this->request('PUT', sprintf('/api/subscribers/%d', $subscriberId), [ + 'json' => [ + 'email' => $email, + 'name' => $name, + 'status' => 'enabled', + 'lists' => $listIds, + 'attribs' => (object) $attribs, + 'preconfirm_subscriptions' => true, + ], + ]); + } + + /** + * @param list $subscriberIds + * @param list $listIds + * + * @throws ListmonkRequestFailed + */ + public function unsubscribeFromLists(array $subscriberIds, array $listIds): void + { + if ($subscriberIds === [] || $listIds === []) { + return; + } + + $this->request('PUT', '/api/subscribers/lists', [ + 'json' => [ + 'ids' => $subscriberIds, + 'action' => 'unsubscribe', + 'target_list_ids' => $listIds, + ], + ]); + } + + /** + * @param list $subscriberIds + * @param list $listIds + * + * @throws ListmonkRequestFailed + */ + public function confirmListSubscriptions(array $subscriberIds, array $listIds): void + { + if ($subscriberIds === [] || $listIds === []) { + return; + } + + $this->request('PUT', '/api/subscribers/lists', [ + 'json' => [ + 'ids' => $subscriberIds, + 'action' => 'add', + 'target_list_ids' => $listIds, + 'status' => 'confirmed', + ], + ]); + } + + /** + * @param list $subscriberIds + * @param list $listIds + * + * @throws ListmonkRequestFailed + */ + public function removeFromLists(array $subscriberIds, array $listIds): void + { + if ($subscriberIds === [] || $listIds === []) { + return; + } + + $this->request('PUT', '/api/subscribers/lists', [ + 'json' => [ + 'ids' => $subscriberIds, + 'action' => 'remove', + 'target_list_ids' => $listIds, + ], + ]); + } + + /** + * @throws ListmonkRequestFailed + */ + public function deleteSubscriber(int $subscriberId): void + { + $this->request('DELETE', sprintf('/api/subscribers/%d', $subscriberId)); + } + + /** + * Bulk CSV import - used for large initial imports instead of thousands of + * single-subscriber API calls. Listmonk processes the import in the + * background; poll getImportStatus() until it finishes. + * + * @param list $listIds + * + * @throws ListmonkRequestFailed + */ + public function importSubscribers(string $csvContent, array $listIds, bool $markConfirmed): void + { + $params = json_encode([ + 'mode' => 'subscribe', + 'delim' => ',', + 'lists' => $listIds, + 'overwrite' => false, + 'subscription_status' => $markConfirmed ? 'confirmed' : 'unconfirmed', + ], JSON_THROW_ON_ERROR); + + $formData = new FormDataPart([ + 'params' => $params, + 'file' => new DataPart($csvContent, 'subscribers.csv', 'text/csv'), + ]); + + $this->request('POST', '/api/import/subscribers', [ + 'headers' => $formData->getPreparedHeaders()->toArray(), + 'body' => $formData->bodyToIterable(), + ]); + } + + /** + * @return array + * + * @throws ListmonkRequestFailed + */ + public function getImportStatus(): array + { + return $this->extractData($this->request('GET', '/api/import/subscribers')); + } + + /** + * @throws ListmonkRequestFailed + */ + public function stopImport(): void + { + $this->request('DELETE', '/api/import/subscribers'); + } + + /** + * @param array $options + * @return array + * + * @throws ListmonkRequestFailed + */ + private function request(string $method, string $path, array $options = []): array + { + $url = rtrim($this->listmonkApiUrl, '/') . $path; + + $headers = $options['headers'] ?? []; + + if (!is_array($headers)) { + $headers = []; + } + + $headers['Authorization'] = sprintf('token %s:%s', $this->listmonkApiUser, $this->listmonkApiToken); + $options['headers'] = $headers; + + try { + $response = $this->client->request($method, $url, $options); + $statusCode = $response->getStatusCode(); + $content = $response->getContent(throw: false); + } catch (TransportException | ExceptionInterface $e) { + $this->logger->error('Listmonk API request failed on transport level', [ + 'method' => $method, + 'path' => $path, + 'exception' => $e, + ]); + + throw new ListmonkRequestFailed(sprintf('Listmonk request %s %s failed: %s', $method, $path, $e->getMessage()), previous: $e); + } + + if ($statusCode >= 400) { + $this->logger->error('Listmonk API request returned an error status', [ + 'method' => $method, + 'path' => $path, + 'status_code' => $statusCode, + 'response_excerpt' => mb_substr($content, 0, 500), + ]); + + throw new ListmonkRequestFailed(sprintf('Listmonk request %s %s returned HTTP %d', $method, $path, $statusCode)); + } + + if ($content === '') { + return []; + } + + try { + /** @var mixed $decoded */ + $decoded = json_decode($content, associative: true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new ListmonkRequestFailed(sprintf('Listmonk request %s %s returned invalid JSON', $method, $path), previous: $e); + } + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $response + * @return array + */ + private function extractData(array $response): array + { + $data = $response['data'] ?? null; + + return is_array($data) ? $data : []; + } + + /** + * @return list> + */ + private function listOfArrays(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $items = []; + + foreach ($value as $item) { + if (is_array($item)) { + $items[] = $item; + } + } + + return $items; + } +} diff --git a/src/Services/Listmonk/ListmonkNewsletterLists.php b/src/Services/Listmonk/ListmonkNewsletterLists.php new file mode 100644 index 000000000..3cf868789 --- /dev/null +++ b/src/Services/Listmonk/ListmonkNewsletterLists.php @@ -0,0 +1,86 @@ + */ + public const array LOCALES = ['en', 'cs', 'de', 'es', 'fr', 'ja']; + + public const string DEFAULT_LOCALE = 'en'; + + public function __construct( + private ListmonkClient $listmonkClient, + ) { + } + + public static function normalizeLocale(null|string $locale): string + { + if ($locale !== null && in_array($locale, self::LOCALES, true)) { + return $locale; + } + + return self::DEFAULT_LOCALE; + } + + public static function listName(string $locale): string + { + return 'Newsletter ' . strtoupper($locale); + } + + /** + * Returns the locale => list id map, creating any missing list. Lists are + * private (not offered on Listmonk's public pages - the website footer form + * is the only public entry) and single opt-in, because MySpeedPuzzling + * handles the double opt-in itself and always syncs with preconfirmed + * subscriptions. + * + * @return array + * + * @throws ListmonkRequestFailed + */ + public function ensureListsExist(): array + { + $existingByName = []; + + foreach ($this->listmonkClient->getLists() as $list) { + $name = $list['name'] ?? null; + $id = $list['id'] ?? null; + + if (is_string($name) && is_numeric($id)) { + $existingByName[$name] = (int) $id; + } + } + + $listIdByLocale = []; + + foreach (self::LOCALES as $locale) { + $name = self::listName($locale); + + if (isset($existingByName[$name])) { + $listIdByLocale[$locale] = $existingByName[$name]; + continue; + } + + $created = $this->listmonkClient->createList($name, type: 'private', optin: 'single', tags: ['newsletter']); + $createdId = $created['id'] ?? null; + + if (!is_numeric($createdId)) { + throw new ListmonkRequestFailed(sprintf('Creating Listmonk list "%s" did not return an id', $name)); + } + + $listIdByLocale[$locale] = (int) $createdId; + } + + return $listIdByLocale; + } +} diff --git a/src/Services/Listmonk/NewsletterAttributesBuilder.php b/src/Services/Listmonk/NewsletterAttributesBuilder.php new file mode 100644 index 000000000..90028c816 --- /dev/null +++ b/src/Services/Listmonk/NewsletterAttributesBuilder.php @@ -0,0 +1,59 @@ + + */ + public function build(NewsletterRecipient $recipient): array + { + $locale = ListmonkNewsletterLists::normalizeLocale($recipient->locale); + + $unsubscribeToken = $this->tokenSigner->generateUnsubscribeToken( + $recipient->audience, + $recipient->id, + $recipient->email, + ); + + $attributes = [ + 'locale' => $locale, + 'audience' => $recipient->audience->value, + 'unsubscribe_url' => $this->urlGenerator->generate( + 'newsletter_unsubscribe', + ['_locale' => $locale, 'token' => $unsubscribeToken], + UrlGeneratorInterface::ABSOLUTE_URL, + ), + ]; + + if ($recipient->audience === NewsletterAudience::Player) { + $attributes['manage_url'] = $this->urlGenerator->generate( + 'edit_profile', + ['_locale' => $locale], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + } + + return $attributes; + } +} diff --git a/src/Services/Listmonk/NewsletterSyncPlanner.php b/src/Services/Listmonk/NewsletterSyncPlanner.php new file mode 100644 index 000000000..267a0e190 --- /dev/null +++ b/src/Services/Listmonk/NewsletterSyncPlanner.php @@ -0,0 +1,189 @@ + $desired + * @param list $actual + * @param array $listIdByLocale + */ + public function plan(array $desired, array $actual, array $listIdByLocale): NewsletterSyncPlan + { + $newsletterListIds = array_values($listIdByLocale); + + $desiredByEmail = []; + + foreach ($desired as $desiredSubscriber) { + $desiredByEmail[$desiredSubscriber->recipient->email] = $desiredSubscriber; + } + + $pullUnsubscribes = []; + $creates = []; + $updates = []; + $listUnsubscribes = []; + $deletions = []; + + $actualEmails = []; + + foreach ($actual as $subscriber) { + $actualEmails[$subscriber->email] = true; + $desiredSubscriber = $desiredByEmail[$subscriber->email] ?? null; + + if ($desiredSubscriber === null) { + $memberOfNewsletterLists = $subscriber->newsletterListIds($newsletterListIds); + + // Not in any newsletter list -> not managed by this sync + if ($memberOfNewsletterLists === []) { + continue; + } + + $foreignLists = $subscriber->foreignListIds($newsletterListIds); + + $deletions[] = new NewsletterSyncDeletion( + listmonkId: $subscriber->id, + fullDelete: $foreignLists === [], + removeFromListIds: $memberOfNewsletterLists, + ); + + continue; + } + + if ($subscriber->isBlocklisted()) { + continue; + } + + $recipient = $desiredSubscriber->recipient; + $targetListId = $this->targetListId($listIdByLocale, $recipient->locale); + + if ($recipient->subscribed) { + if ($subscriber->isUnsubscribedFromAnyNewsletterList($newsletterListIds)) { + $pullUnsubscribes[] = $recipient; + + continue; + } + + $targetListIds = [...$subscriber->foreignListIds($newsletterListIds), $targetListId]; + sort($targetListIds); + + if ($this->needsUpdate($desiredSubscriber, $subscriber, $targetListId, $newsletterListIds)) { + $updates[] = new NewsletterSyncUpdate( + listmonkId: $subscriber->id, + desired: $desiredSubscriber, + targetListIds: $targetListIds, + ); + } + + continue; + } + + // Desired unsubscribed: make sure no newsletter list still delivers + $stillActiveListIds = []; + + foreach ($subscriber->newsletterListIds($newsletterListIds) as $listId) { + if ($subscriber->listStatuses[$listId] !== 'unsubscribed') { + $stillActiveListIds[] = $listId; + } + } + + if ($stillActiveListIds !== []) { + $listUnsubscribes[] = new NewsletterSyncListUnsubscribe( + listmonkId: $subscriber->id, + listIds: $stillActiveListIds, + ); + } + } + + foreach ($desiredByEmail as $email => $desiredSubscriber) { + if (isset($actualEmails[$email])) { + continue; + } + + // Never-synced unsubscribed recipients do not need a suppression row + if ($desiredSubscriber->recipient->subscribed) { + $creates[] = $desiredSubscriber; + } + } + + return new NewsletterSyncPlan( + pullUnsubscribes: $pullUnsubscribes, + creates: $creates, + updates: $updates, + listUnsubscribes: $listUnsubscribes, + deletions: $deletions, + ); + } + + /** + * @param array $listIdByLocale + */ + public function targetListId(array $listIdByLocale, null|string $locale): int + { + $normalized = ListmonkNewsletterLists::normalizeLocale($locale); + + return $listIdByLocale[$normalized] ?? $listIdByLocale[ListmonkNewsletterLists::DEFAULT_LOCALE] ?? 0; + } + + /** + * @param list $newsletterListIds + */ + private function needsUpdate( + DesiredNewsletterSubscriber $desired, + ListmonkSubscriber $subscriber, + int $targetListId, + array $newsletterListIds, + ): bool { + if ($subscriber->status !== 'enabled') { + return true; + } + + if ($subscriber->name !== $desired->recipient->name) { + return true; + } + + $currentNewsletterLists = $subscriber->newsletterListIds($newsletterListIds); + sort($currentNewsletterLists); + + if ($currentNewsletterLists !== [$targetListId]) { + return true; + } + + if (($subscriber->listStatuses[$targetListId] ?? null) !== 'confirmed') { + return true; + } + + foreach ($desired->attributes as $key => $value) { + $actualValue = $subscriber->attribs[$key] ?? null; + + if (!is_scalar($actualValue) || (string) $actualValue !== $value) { + return true; + } + } + + return false; + } +} diff --git a/src/Services/NewsletterTokenSigner.php b/src/Services/NewsletterTokenSigner.php new file mode 100644 index 000000000..5b5da2ae9 --- /dev/null +++ b/src/Services/NewsletterTokenSigner.php @@ -0,0 +1,195 @@ +encode([ + 'type' => self::TYPE_UNSUBSCRIBE, + 'audience' => $audience->value, + 'id' => $id, + 'email' => mb_strtolower(trim($email)), + ]); + } + + public function generateConfirmToken(NewsletterAudience $audience, string $id, string $email): string + { + $expiresAt = $this->clock->now()->modify('+' . self::CONFIRM_LIFETIME_HOURS . ' hours'); + + return $this->encode([ + 'type' => self::TYPE_CONFIRM, + 'audience' => $audience->value, + 'id' => $id, + 'email' => mb_strtolower(trim($email)), + 'expiresAt' => $expiresAt->getTimestamp(), + ]); + } + + /** + * @throws InvalidNewsletterToken + */ + public function parseUnsubscribeToken(#[SensitiveParameter] string $token): NewsletterUnsubscribeClaim + { + $claims = $this->decode($token); + + if (($claims['type'] ?? null) !== self::TYPE_UNSUBSCRIBE) { + throw new InvalidNewsletterToken(); + } + + [$audience, $id, $email] = $this->readCommonClaims($claims); + + return new NewsletterUnsubscribeClaim($audience, $id, $email); + } + + /** + * @throws InvalidNewsletterToken + * @throws NewsletterConfirmTokenExpired + */ + public function parseConfirmToken(#[SensitiveParameter] string $token): NewsletterConfirmClaim + { + $claims = $this->decode($token); + + if (($claims['type'] ?? null) !== self::TYPE_CONFIRM) { + throw new InvalidNewsletterToken(); + } + + [$audience, $id, $email] = $this->readCommonClaims($claims); + $expiresAt = $claims['expiresAt'] ?? null; + + if (!is_int($expiresAt)) { + throw new InvalidNewsletterToken(); + } + + // Only after the signature check - an attacker must not learn anything from a forged expiry + if ($expiresAt <= $this->clock->now()->getTimestamp()) { + throw new NewsletterConfirmTokenExpired(); + } + + return new NewsletterConfirmClaim($audience, $id, $email); + } + + /** + * @param array $claims + * @return array{NewsletterAudience, string, string} + * + * @throws InvalidNewsletterToken + */ + private function readCommonClaims(array $claims): array + { + $audience = $claims['audience'] ?? null; + $id = $claims['id'] ?? null; + $email = $claims['email'] ?? null; + + if (!is_string($audience) || !is_string($id) || !is_string($email)) { + throw new InvalidNewsletterToken(); + } + + $audienceEnum = NewsletterAudience::tryFrom($audience); + + if ($audienceEnum === null) { + throw new InvalidNewsletterToken(); + } + + return [$audienceEnum, $id, $email]; + } + + /** + * @param array $claims + */ + private function encode(array $claims): string + { + $payload = json_encode($claims, JSON_THROW_ON_ERROR); + + return self::base64UrlEncode($payload) . '.' . self::base64UrlEncode($this->sign($payload)); + } + + /** + * @return array + * + * @throws InvalidNewsletterToken + */ + private function decode(#[SensitiveParameter] string $token): array + { + $parts = explode('.', $token); + + if (count($parts) !== 2) { + throw new InvalidNewsletterToken(); + } + + $payload = self::base64UrlDecode($parts[0]); + $signature = self::base64UrlDecode($parts[1]); + + if ($payload === null || $signature === null) { + throw new InvalidNewsletterToken(); + } + + if (!hash_equals($this->sign($payload), $signature)) { + throw new InvalidNewsletterToken(); + } + + try { + /** @var mixed $claims */ + $claims = json_decode($payload, associative: true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw new InvalidNewsletterToken(); + } + + if (!is_array($claims)) { + throw new InvalidNewsletterToken(); + } + + return $claims; + } + + private function sign(string $payload): string + { + return hash_hmac('sha256', 'newsletter.' . $payload, $this->secret, binary: true); + } + + private static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private static function base64UrlDecode(string $data): null|string + { + $decoded = base64_decode(strtr($data, '-_', '+/'), strict: true); + + return $decoded === false ? null : $decoded; + } +} diff --git a/src/Value/DesiredNewsletterSubscriber.php b/src/Value/DesiredNewsletterSubscriber.php new file mode 100644 index 000000000..d7d416f6f --- /dev/null +++ b/src/Value/DesiredNewsletterSubscriber.php @@ -0,0 +1,21 @@ + */ + public array $attributes, + ) { + } +} diff --git a/src/Value/ListmonkSubscriber.php b/src/Value/ListmonkSubscriber.php new file mode 100644 index 000000000..b1a1d5f08 --- /dev/null +++ b/src/Value/ListmonkSubscriber.php @@ -0,0 +1,109 @@ + list id -> subscription status (unconfirmed | confirmed | unsubscribed) */ + public array $listStatuses, + /** @var array */ + public array $attribs, + ) { + } + + /** + * @param array $data + */ + public static function fromApi(array $data): null|self + { + $id = $data['id'] ?? null; + $email = $data['email'] ?? null; + + if (!is_numeric($id) || !is_string($email) || $email === '') { + return null; + } + + $listStatuses = []; + $lists = $data['lists'] ?? null; + + if (is_array($lists)) { + foreach ($lists as $list) { + if (!is_array($list)) { + continue; + } + + $listId = $list['id'] ?? null; + $subscriptionStatus = $list['subscription_status'] ?? null; + + if (is_numeric($listId) && is_string($subscriptionStatus)) { + $listStatuses[(int) $listId] = $subscriptionStatus; + } + } + } + + $attribs = $data['attribs'] ?? null; + $name = $data['name'] ?? null; + $status = $data['status'] ?? null; + + /** @var array $attribsArray */ + $attribsArray = is_array($attribs) ? $attribs : []; + + return new self( + id: (int) $id, + email: mb_strtolower(trim($email)), + name: is_string($name) ? $name : '', + status: is_string($status) ? $status : 'enabled', + listStatuses: $listStatuses, + attribs: $attribsArray, + ); + } + + public function isBlocklisted(): bool + { + return $this->status === 'blocklisted'; + } + + /** + * @param list $newsletterListIds + * @return list + */ + public function newsletterListIds(array $newsletterListIds): array + { + return array_values(array_intersect(array_keys($this->listStatuses), $newsletterListIds)); + } + + /** + * @param list $newsletterListIds + * @return list + */ + public function foreignListIds(array $newsletterListIds): array + { + return array_values(array_diff(array_keys($this->listStatuses), $newsletterListIds)); + } + + /** + * @param list $newsletterListIds + */ + public function isUnsubscribedFromAnyNewsletterList(array $newsletterListIds): bool + { + foreach ($this->newsletterListIds($newsletterListIds) as $listId) { + if ($this->listStatuses[$listId] === 'unsubscribed') { + return true; + } + } + + return false; + } +} diff --git a/src/Value/NewsletterAudience.php b/src/Value/NewsletterAudience.php new file mode 100644 index 000000000..4ab617e55 --- /dev/null +++ b/src/Value/NewsletterAudience.php @@ -0,0 +1,16 @@ + newsletter lists to remove the subscriber from when not fully deleting */ + public array $removeFromListIds, + ) { + } +} diff --git a/src/Value/NewsletterSyncListUnsubscribe.php b/src/Value/NewsletterSyncListUnsubscribe.php new file mode 100644 index 000000000..80614a5d7 --- /dev/null +++ b/src/Value/NewsletterSyncListUnsubscribe.php @@ -0,0 +1,15 @@ + */ + public array $listIds, + ) { + } +} diff --git a/src/Value/NewsletterSyncPlan.php b/src/Value/NewsletterSyncPlan.php new file mode 100644 index 000000000..6a1e2f684 --- /dev/null +++ b/src/Value/NewsletterSyncPlan.php @@ -0,0 +1,33 @@ + unsubscribed in Listmonk -> propagate to MySpeedPuzzling */ + public array $pullUnsubscribes, + /** @var list missing in Listmonk -> create */ + public array $creates, + /** @var list drifted in Listmonk -> update */ + public array $updates, + /** @var list unsubscribed in MySpeedPuzzling -> mark unsubscribed in Listmonk */ + public array $listUnsubscribes, + /** @var list gone from MySpeedPuzzling -> remove from Listmonk */ + public array $deletions, + ) { + } + + public function isEmpty(): bool + { + return $this->pullUnsubscribes === [] + && $this->creates === [] + && $this->updates === [] + && $this->listUnsubscribes === [] + && $this->deletions === []; + } +} diff --git a/src/Value/NewsletterSyncUpdate.php b/src/Value/NewsletterSyncUpdate.php new file mode 100644 index 000000000..11c7fc1bc --- /dev/null +++ b/src/Value/NewsletterSyncUpdate.php @@ -0,0 +1,16 @@ + full replacement set of list ids (foreign lists preserved) */ + public array $targetListIds, + ) { + } +} diff --git a/src/Value/NewsletterUnsubscribeClaim.php b/src/Value/NewsletterUnsubscribeClaim.php new file mode 100644 index 000000000..709a72da0 --- /dev/null +++ b/src/Value/NewsletterUnsubscribeClaim.php @@ -0,0 +1,15 @@ +
+ +