diff --git a/.env b/.env index f74eb9f9..d8d43ac5 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 37ada11b..7ff64415 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 63a85824..e9f3be7b 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 e25f3fe6..4706aa32 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 531789f2..3b9450f1 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 8c1ac47f..63aa84de 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 da2932be..5058e1b9 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 2d456700..5f890cf1 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 00000000..bf0e6434 --- /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 00000000..6d8b6295 --- /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 00000000..c513b9b4 --- /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 00000000..25b24fad --- /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 00000000..e426b6fc --- /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 00000000..4562ab26 --- /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 00000000..4e8fe913 --- /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 00000000..ad136bf3 --- /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 00000000..c9f14c12 --- /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 00000000..96a875ee --- /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 00000000..edc1390d --- /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 bfece437..1e79aa65 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 aa324f63..724a39bf 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 00000000..f8500920 --- /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 00000000..1ce4c9fb --- /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 00000000..8fc3b714 --- /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 00000000..7aefef4d --- /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 00000000..cc7a3da4 --- /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 00000000..4c173465 --- /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 00000000..69fbf6cf --- /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 00000000..ef8412c5 --- /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 00000000..3cf86878 --- /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 00000000..90028c81 --- /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 00000000..267a0e19 --- /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 00000000..5b5da2ae --- /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 00000000..d7d416f6 --- /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 00000000..b1a1d5f0 --- /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 00000000..4ab617e5 --- /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 00000000..80614a5d --- /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 00000000..6a1e2f68 --- /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 00000000..11c7fc1b --- /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 00000000..709a72da --- /dev/null +++ b/src/Value/NewsletterUnsubscribeClaim.php @@ -0,0 +1,15 @@ + -
-
-

- - -

- {{ 'footer.created_with_love'|trans({'%simona%': 'Simona Mikešová', '%jan%': 'Jan Mikeš' })|raw }} +
+
+
+

{{ 'newsletter.footer.title'|trans }}

+

{{ 'newsletter.footer.pitch'|trans }}

+ + {% if logged_user.profile is null %} + {# data-turbo=false: a Turbo-driven redirect keeps the footer scroll position, + landing the visitor at the bottom of the check-inbox page #} +
+ +
+ + +
+

{{ 'newsletter.footer.consent'|trans({'%privacyUrl%': path('privacy_policy')})|raw }}

+
+ {% else %} +

+ {{ 'newsletter.footer.manage_link'|trans }} +

+ {% endif %} +
+ +
+

+ + +

+ {{ 'footer.created_with_love'|trans({'%simona%': 'Simona Mikešová', '%jan%': 'Jan Mikeš' })|raw }} +
diff --git a/templates/emails/newsletter_confirmation.html.twig b/templates/emails/newsletter_confirmation.html.twig new file mode 100644 index 00000000..d3e46e7b --- /dev/null +++ b/templates/emails/newsletter_confirmation.html.twig @@ -0,0 +1,39 @@ +{% trans_default_domain 'emails' %} +{% apply inky_to_html|inline_css(source('@styles/foundation-emails.css')) %} + + + {{ include('emails/_header.html.twig') }} + + + + +

{{ 'newsletter_confirmation.title'|trans }}

+
+
+ + + + {{ 'newsletter_confirmation.content'|trans|raw }} + + + + + + +
+ {{ 'newsletter_confirmation.button'|trans }} +
+ +
+
+ + + +

{{ 'newsletter_confirmation.expires'|trans({'%hours%': expiresHours}) }}

+
+
+ + {{ include('emails/_footer.html.twig') }} +
+
+{% endapply %} diff --git a/templates/newsletter/check-inbox.html.twig b/templates/newsletter/check-inbox.html.twig new file mode 100644 index 00000000..26cf9bfc --- /dev/null +++ b/templates/newsletter/check-inbox.html.twig @@ -0,0 +1,31 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'newsletter.check_inbox.headline'|trans }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ 'newsletter.check_inbox.headline'|trans }}

+ + + +

{{ 'newsletter.check_inbox.spam_hint'|trans }}

+ +

{{ 'newsletter.check_inbox.expires'|trans({'%hours%': expiresHours}) }}

+ +

+ + {{ 'newsletter.check_inbox.cta_homepage'|trans }} + +

+
+ +
+ +
+
+{% endblock %} diff --git a/templates/newsletter/confirmed.html.twig b/templates/newsletter/confirmed.html.twig new file mode 100644 index 00000000..d4eeb9c5 --- /dev/null +++ b/templates/newsletter/confirmed.html.twig @@ -0,0 +1,40 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'newsletter.confirm.headline'|trans }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ 'newsletter.confirm.headline'|trans }}

+ + + + {% if not isPlayer %} +

{{ 'newsletter.confirm.register_pitch'|trans }}

+ {% endif %} + +

+ {% if not isPlayer %} + + {{ 'newsletter.confirm.cta_register'|trans }} + + + {{ 'newsletter.confirm.cta_homepage'|trans }} + + {% else %} + + {{ 'newsletter.confirm.cta_homepage'|trans }} + + {% endif %} +

+
+ +
+ +
+
+{% endblock %} diff --git a/templates/newsletter/invalid-token.html.twig b/templates/newsletter/invalid-token.html.twig new file mode 100644 index 00000000..1378c928 --- /dev/null +++ b/templates/newsletter/invalid-token.html.twig @@ -0,0 +1,27 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'newsletter.invalid.headline'|trans }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ 'newsletter.invalid.headline'|trans }}

+ + + +

+ + {{ 'newsletter.invalid.cta_homepage'|trans }} + +

+
+ +
+ +
+
+{% endblock %} diff --git a/templates/newsletter/unsubscribe.html.twig b/templates/newsletter/unsubscribe.html.twig new file mode 100644 index 00000000..9dfaab29 --- /dev/null +++ b/templates/newsletter/unsubscribe.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'newsletter.unsubscribe.headline'|trans }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ 'newsletter.unsubscribe.headline'|trans }}

+ +

{{ 'newsletter.unsubscribe.text'|trans({'%email%': email})|raw }}

+ +
+ + +
+ + {% if isPlayer %} +

{{ 'newsletter.unsubscribe.manage_hint'|trans }}

+

+ + {{ 'newsletter.unsubscribe.manage_button'|trans }} + +

+ {% endif %} +
+ +
+ +
+
+{% endblock %} diff --git a/templates/newsletter/unsubscribed.html.twig b/templates/newsletter/unsubscribed.html.twig new file mode 100644 index 00000000..bbece236 --- /dev/null +++ b/templates/newsletter/unsubscribed.html.twig @@ -0,0 +1,35 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'newsletter.unsubscribed.headline'|trans }}{% endblock %} +{% block robots %}{% endblock %} + +{% block content %} +
+
+

{{ 'newsletter.unsubscribed.headline'|trans }}

+ + + +

+ {% if isPlayer %} + {{ 'newsletter.unsubscribed.resubscribe_player'|trans({'%settingsUrl%': path('edit_profile')})|raw }} + {% else %} + {{ 'newsletter.unsubscribed.resubscribe_guest'|trans }} + {% endif %} +

+ +

+ + {{ 'newsletter.unsubscribed.cta_homepage'|trans }} + +

+
+ +
+ +
+
+{% endblock %} diff --git a/tests/MessageHandler/ConfirmNewsletterSubscriptionHandlerTest.php b/tests/MessageHandler/ConfirmNewsletterSubscriptionHandlerTest.php new file mode 100644 index 00000000..7e08e402 --- /dev/null +++ b/tests/MessageHandler/ConfirmNewsletterSubscriptionHandlerTest.php @@ -0,0 +1,98 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->subscriberRepository = $container->get(NewsletterSubscriberRepository::class); + $this->playerRepository = $container->get(PlayerRepository::class); + $this->tokenSigner = $container->get(NewsletterTokenSigner::class); + } + + public function testGuestConfirmation(): void + { + $this->messageBus->dispatch(new SubscribeToNewsletter('confirm-me@example.com', 'en', null)); + + $subscriber = $this->subscriberRepository->findByEmail('confirm-me@example.com'); + self::assertNotNull($subscriber); + + $token = $this->tokenSigner->generateConfirmToken(NewsletterAudience::Guest, $subscriber->id->toString(), $subscriber->email); + + $this->messageBus->dispatch(new ConfirmNewsletterSubscription($token)); + + $confirmed = $this->subscriberRepository->findByEmail('confirm-me@example.com'); + self::assertNotNull($confirmed); + self::assertSame(NewsletterSubscriberStatus::Confirmed, $confirmed->status); + self::assertNotNull($confirmed->confirmedAt); + } + + public function testPlayerConfirmationReenablesNewsletter(): void + { + $this->messageBus->dispatch(new EditMessagingSettings( + playerId: PlayerFixture::PLAYER_REGULAR, + allowDirectMessages: true, + emailNotificationsEnabled: true, + emailNotificationFrequency: EmailNotificationFrequency::TwentyFourHours, + newsletterEnabled: false, + )); + + self::assertFalse($this->playerRepository->get(PlayerFixture::PLAYER_REGULAR)->newsletterEnabled); + + $token = $this->tokenSigner->generateConfirmToken( + NewsletterAudience::Player, + PlayerFixture::PLAYER_REGULAR, + PlayerFixture::PLAYER_REGULAR_EMAIL, + ); + + $this->messageBus->dispatch(new ConfirmNewsletterSubscription($token)); + + self::assertTrue($this->playerRepository->get(PlayerFixture::PLAYER_REGULAR)->newsletterEnabled); + } + + public function testTokenForChangedEmailIsRejected(): void + { + $token = $this->tokenSigner->generateConfirmToken( + NewsletterAudience::Player, + PlayerFixture::PLAYER_REGULAR, + 'no-longer-this-address@example.com', + ); + + try { + $this->messageBus->dispatch(new ConfirmNewsletterSubscription($token)); + self::fail('Expected the confirmation to be rejected'); + } catch (HandlerFailedException $exception) { + self::assertInstanceOf(InvalidNewsletterToken::class, $exception->getPrevious()); + } + } +} diff --git a/tests/MessageHandler/SubscribeToNewsletterHandlerTest.php b/tests/MessageHandler/SubscribeToNewsletterHandlerTest.php new file mode 100644 index 00000000..6b0e639c --- /dev/null +++ b/tests/MessageHandler/SubscribeToNewsletterHandlerTest.php @@ -0,0 +1,80 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->subscriberRepository = $container->get(NewsletterSubscriberRepository::class); + } + + public function testGuestSignupCreatesPendingSubscriber(): void + { + $this->messageBus->dispatch(new SubscribeToNewsletter( + email: 'Fresh.Guest@Example.com', + locale: 'cs', + ipAddress: '203.0.113.7', + )); + + $subscriber = $this->subscriberRepository->findByEmail('fresh.guest@example.com'); + + self::assertNotNull($subscriber); + self::assertSame(NewsletterSubscriberStatus::Pending, $subscriber->status); + self::assertSame('cs', $subscriber->locale); + self::assertSame('203.0.113.7', $subscriber->ipAddress); + } + + public function testRepeatedSignupKeepsSingleRowAndRefreshesLocale(): void + { + $this->messageBus->dispatch(new SubscribeToNewsletter('repeat@example.com', 'cs', null)); + $first = $this->subscriberRepository->findByEmail('repeat@example.com'); + self::assertNotNull($first); + + $this->messageBus->dispatch(new SubscribeToNewsletter('repeat@example.com', 'en', '198.51.100.1')); + $second = $this->subscriberRepository->findByEmail('repeat@example.com'); + + self::assertNotNull($second); + self::assertTrue($first->id->equals($second->id)); + self::assertSame('en', $second->locale); + self::assertSame(NewsletterSubscriberStatus::Pending, $second->status); + } + + public function testPlayerEmailDoesNotCreateGuestRow(): void + { + $this->messageBus->dispatch(new SubscribeToNewsletter( + email: PlayerFixture::PLAYER_REGULAR_EMAIL, + locale: 'en', + ipAddress: null, + )); + + self::assertNull($this->subscriberRepository->findByEmail(PlayerFixture::PLAYER_REGULAR_EMAIL)); + } + + public function testUnsupportedLocaleFallsBackToEnglish(): void + { + $this->messageBus->dispatch(new SubscribeToNewsletter('locale-fallback@example.com', 'xx', null)); + + $subscriber = $this->subscriberRepository->findByEmail('locale-fallback@example.com'); + + self::assertNotNull($subscriber); + self::assertSame('en', $subscriber->locale); + } +} diff --git a/tests/MessageHandler/UnsubscribeFromNewsletterHandlerTest.php b/tests/MessageHandler/UnsubscribeFromNewsletterHandlerTest.php new file mode 100644 index 00000000..39335fe4 --- /dev/null +++ b/tests/MessageHandler/UnsubscribeFromNewsletterHandlerTest.php @@ -0,0 +1,73 @@ +messageBus = $container->get(MessageBusInterface::class); + $this->subscriberRepository = $container->get(NewsletterSubscriberRepository::class); + $this->playerRepository = $container->get(PlayerRepository::class); + $this->tokenSigner = $container->get(NewsletterTokenSigner::class); + } + + public function testPlayerUnsubscribes(): void + { + self::assertTrue($this->playerRepository->get(PlayerFixture::PLAYER_REGULAR)->newsletterEnabled); + + $token = $this->tokenSigner->generateUnsubscribeToken( + NewsletterAudience::Player, + PlayerFixture::PLAYER_REGULAR, + PlayerFixture::PLAYER_REGULAR_EMAIL, + ); + + $this->messageBus->dispatch(new UnsubscribeFromNewsletter($token)); + + self::assertFalse($this->playerRepository->get(PlayerFixture::PLAYER_REGULAR)->newsletterEnabled); + } + + public function testGuestUnsubscribes(): void + { + $this->messageBus->dispatch(new SubscribeToNewsletter('leaving@example.com', 'en', null)); + + $subscriber = $this->subscriberRepository->findByEmail('leaving@example.com'); + self::assertNotNull($subscriber); + + $confirmToken = $this->tokenSigner->generateConfirmToken(NewsletterAudience::Guest, $subscriber->id->toString(), $subscriber->email); + $this->messageBus->dispatch(new ConfirmNewsletterSubscription($confirmToken)); + + $unsubscribeToken = $this->tokenSigner->generateUnsubscribeToken(NewsletterAudience::Guest, $subscriber->id->toString(), $subscriber->email); + $this->messageBus->dispatch(new UnsubscribeFromNewsletter($unsubscribeToken)); + + $unsubscribed = $this->subscriberRepository->findByEmail('leaving@example.com'); + self::assertNotNull($unsubscribed); + self::assertSame(NewsletterSubscriberStatus::Unsubscribed, $unsubscribed->status); + self::assertNotNull($unsubscribed->unsubscribedAt); + } +} diff --git a/tests/Services/NewsletterSyncPlannerTest.php b/tests/Services/NewsletterSyncPlannerTest.php new file mode 100644 index 00000000..95acef6d --- /dev/null +++ b/tests/Services/NewsletterSyncPlannerTest.php @@ -0,0 +1,206 @@ + 1, 'cs' => 2, 'de' => 3, 'es' => 4, 'fr' => 5, 'ja' => 6]; + private const int FOREIGN_LIST_ID = 99; + + private NewsletterSyncPlanner $planner; + + protected function setUp(): void + { + $this->planner = new NewsletterSyncPlanner(); + } + + public function testMissingSubscribedRecipientIsCreated(): void + { + $plan = $this->planner->plan([self::desired('new@example.com', subscribed: true, locale: 'cs')], [], self::LIST_IDS); + + self::assertCount(1, $plan->creates); + self::assertSame('new@example.com', $plan->creates[0]->recipient->email); + self::assertSame([], $plan->updates); + } + + public function testMissingUnsubscribedRecipientIsNotCreated(): void + { + $plan = $this->planner->plan([self::desired('gone@example.com', subscribed: false)], [], self::LIST_IDS); + + self::assertTrue($plan->isEmpty()); + } + + public function testConsistentSubscriberProducesNoChanges(): void + { + $desired = self::desired('same@example.com', subscribed: true, locale: 'cs'); + + $actual = self::actual(10, 'same@example.com', [2 => 'confirmed'], attribs: $desired->attributes); + + $plan = $this->planner->plan([$desired], [$actual], self::LIST_IDS); + + self::assertTrue($plan->isEmpty()); + } + + public function testListmonkUnsubscribeIsPulled(): void + { + $desired = self::desired('left@example.com', subscribed: true, locale: 'cs'); + $actual = self::actual(11, 'left@example.com', [2 => 'unsubscribed'], attribs: $desired->attributes); + + $plan = $this->planner->plan([$desired], [$actual], self::LIST_IDS); + + self::assertCount(1, $plan->pullUnsubscribes); + self::assertSame('left@example.com', $plan->pullUnsubscribes[0]->email); + // Never also updated in the same run - the pull wins + self::assertSame([], $plan->updates); + } + + public function testMspUnsubscribeIsPushedToListmonk(): void + { + $desired = self::desired('optout@example.com', subscribed: false, locale: 'cs'); + $actual = self::actual(12, 'optout@example.com', [2 => 'confirmed']); + + $plan = $this->planner->plan([$desired], [$actual], self::LIST_IDS); + + self::assertCount(1, $plan->listUnsubscribes); + self::assertSame(12, $plan->listUnsubscribes[0]->listmonkId); + self::assertSame([2], $plan->listUnsubscribes[0]->listIds); + } + + public function testAlreadyUnsubscribedEverywhereIsLeftAlone(): void + { + $desired = self::desired('quiet@example.com', subscribed: false, locale: 'cs'); + $actual = self::actual(13, 'quiet@example.com', [2 => 'unsubscribed']); + + $plan = $this->planner->plan([$desired], [$actual], self::LIST_IDS); + + self::assertTrue($plan->isEmpty()); + } + + public function testOrphanInNewsletterListOnlyIsFullyDeleted(): void + { + $actual = self::actual(14, 'deleted-player@example.com', [1 => 'confirmed']); + + $plan = $this->planner->plan([], [$actual], self::LIST_IDS); + + self::assertCount(1, $plan->deletions); + self::assertTrue($plan->deletions[0]->fullDelete); + } + + public function testOrphanWithForeignListOnlyLosesNewsletterMembership(): void + { + $actual = self::actual(15, 'foreign@example.com', [1 => 'confirmed', self::FOREIGN_LIST_ID => 'confirmed']); + + $plan = $this->planner->plan([], [$actual], self::LIST_IDS); + + self::assertCount(1, $plan->deletions); + self::assertFalse($plan->deletions[0]->fullDelete); + self::assertSame([1], $plan->deletions[0]->removeFromListIds); + } + + public function testSubscriberOutsideNewsletterListsIsNotManaged(): void + { + $actual = self::actual(16, 'unrelated@example.com', [self::FOREIGN_LIST_ID => 'confirmed']); + + $plan = $this->planner->plan([], [$actual], self::LIST_IDS); + + self::assertTrue($plan->isEmpty()); + } + + public function testBlocklistedSubscriberIsNeverTouched(): void + { + $desired = self::desired('bounced@example.com', subscribed: true, locale: 'cs'); + $actual = self::actual(17, 'bounced@example.com', [2 => 'unsubscribed'], status: 'blocklisted'); + + $plan = $this->planner->plan([$desired], [$actual], self::LIST_IDS); + + self::assertTrue($plan->isEmpty()); + } + + public function testLocaleChangeMovesSubscriberBetweenLists(): void + { + $desired = self::desired('mover@example.com', subscribed: true, locale: 'en'); + $actual = self::actual(18, 'mover@example.com', [2 => 'confirmed'], attribs: $desired->attributes); + + $plan = $this->planner->plan([$desired], [$actual], self::LIST_IDS); + + self::assertCount(1, $plan->updates); + self::assertSame([1], $plan->updates[0]->targetListIds); + } + + public function testForeignListsArePreservedOnUpdate(): void + { + $desired = self::desired('both@example.com', subscribed: true, locale: 'cs'); + $actual = self::actual(19, 'both@example.com', [1 => 'confirmed', self::FOREIGN_LIST_ID => 'confirmed'], attribs: $desired->attributes); + + $plan = $this->planner->plan([$desired], [$actual], self::LIST_IDS); + + self::assertCount(1, $plan->updates); + self::assertSame([2, self::FOREIGN_LIST_ID], $plan->updates[0]->targetListIds); + } + + public function testAttributeDriftTriggersUpdate(): void + { + $desired = self::desired('drift@example.com', subscribed: true, locale: 'cs'); + $staleAttribs = $desired->attributes; + $staleAttribs['unsubscribe_url'] = 'https://old.example.com/unsubscribe'; + + $actual = self::actual(20, 'drift@example.com', [2 => 'confirmed'], attribs: $staleAttribs); + + $plan = $this->planner->plan([$desired], [$actual], self::LIST_IDS); + + self::assertCount(1, $plan->updates); + } + + public function testUnconfirmedTargetMembershipTriggersUpdate(): void + { + $desired = self::desired('unconfirmed@example.com', subscribed: true, locale: 'cs'); + $actual = self::actual(21, 'unconfirmed@example.com', [2 => 'unconfirmed'], attribs: $desired->attributes); + + $plan = $this->planner->plan([$desired], [$actual], self::LIST_IDS); + + self::assertCount(1, $plan->updates); + } + + private static function desired(string $email, bool $subscribed, string $locale = 'en', string $name = 'Test'): DesiredNewsletterSubscriber + { + $recipient = new NewsletterRecipient( + audience: NewsletterAudience::Player, + id: '018d0000-0000-0000-0000-00000000abcd', + email: $email, + name: $name, + locale: $locale, + subscribed: $subscribed, + ); + + return new DesiredNewsletterSubscriber($recipient, [ + 'locale' => $locale, + 'audience' => 'player', + 'unsubscribe_url' => 'https://example.com/unsubscribe/' . $email, + ]); + } + + /** + * @param array $listStatuses + * @param array $attribs + */ + private static function actual(int $id, string $email, array $listStatuses, string $status = 'enabled', array $attribs = [], string $name = 'Test'): ListmonkSubscriber + { + return new ListmonkSubscriber( + id: $id, + email: $email, + name: $name, + status: $status, + listStatuses: $listStatuses, + attribs: $attribs, + ); + } +} diff --git a/tests/Services/NewsletterTokenSignerTest.php b/tests/Services/NewsletterTokenSignerTest.php new file mode 100644 index 00000000..6a3b55d4 --- /dev/null +++ b/tests/Services/NewsletterTokenSignerTest.php @@ -0,0 +1,90 @@ +clock = new MockClock('2026-07-26 12:00:00'); + $this->signer = new NewsletterTokenSigner('test-secret', $this->clock); + } + + public function testUnsubscribeTokenRoundtrip(): void + { + $token = $this->signer->generateUnsubscribeToken(NewsletterAudience::Player, 'player-id', 'Someone@Example.com '); + + $claim = $this->signer->parseUnsubscribeToken($token); + + self::assertSame(NewsletterAudience::Player, $claim->audience); + self::assertSame('player-id', $claim->id); + self::assertSame('someone@example.com', $claim->email); + } + + public function testUnsubscribeTokenIsDeterministic(): void + { + $first = $this->signer->generateUnsubscribeToken(NewsletterAudience::Guest, 'id', 'a@b.com'); + $second = $this->signer->generateUnsubscribeToken(NewsletterAudience::Guest, 'id', 'a@b.com'); + + self::assertSame($first, $second); + } + + public function testConfirmTokenRoundtrip(): void + { + $token = $this->signer->generateConfirmToken(NewsletterAudience::Guest, 'guest-id', 'guest@example.com'); + + $claim = $this->signer->parseConfirmToken($token); + + self::assertSame(NewsletterAudience::Guest, $claim->audience); + self::assertSame('guest-id', $claim->id); + self::assertSame('guest@example.com', $claim->email); + } + + public function testConfirmTokenExpires(): void + { + $token = $this->signer->generateConfirmToken(NewsletterAudience::Guest, 'guest-id', 'guest@example.com'); + + $this->clock->modify('+49 hours'); + + $this->expectException(NewsletterConfirmTokenExpired::class); + $this->signer->parseConfirmToken($token); + } + + public function testTamperedTokenIsRejected(): void + { + $token = $this->signer->generateUnsubscribeToken(NewsletterAudience::Player, 'player-id', 'someone@example.com'); + [$payload, $signature] = explode('.', $token); + + $forgedPayload = rtrim(strtr(base64_encode(str_replace('player-id', 'other-id', (string) base64_decode(strtr($payload, '-_', '+/'), true))), '+/', '-_'), '='); + + $this->expectException(InvalidNewsletterToken::class); + $this->signer->parseUnsubscribeToken($forgedPayload . '.' . $signature); + } + + public function testConfirmTokenCannotBeUsedAsUnsubscribeToken(): void + { + $token = $this->signer->generateConfirmToken(NewsletterAudience::Guest, 'guest-id', 'guest@example.com'); + + $this->expectException(InvalidNewsletterToken::class); + $this->signer->parseUnsubscribeToken($token); + } + + public function testGarbageIsRejected(): void + { + $this->expectException(InvalidNewsletterToken::class); + $this->signer->parseUnsubscribeToken('not-a-token'); + } +} diff --git a/translations/emails.cs.yml b/translations/emails.cs.yml index 302a6e74..b5d68d6d 100644 --- a/translations/emails.cs.yml +++ b/translations/emails.cs.yml @@ -177,3 +177,12 @@ password_reset: button: "Zvolit nové heslo" expires: "Odkaz funguje jednou a vyprší za %minutes% minut." not_requested: "Nežádali jste o něj? Tento e-mail můžete klidně ignorovat — vaše heslo zůstane beze změny." + +newsletter_confirmation: + subject: "Jedno kliknutí a puzzle novinky jsou vaše 🧩" + title: "Potvrďte odběr newsletteru" + content: | +

Ahoj! Někdo — doufejme, že vy — se přihlásil k odběru newsletteru MySpeedPuzzling na tuto adresu.

+

Potvrďte odběr tlačítkem níže a budeme vám dávat vědět, co je na platformě nového — nové funkce, vylepšení a novinky z komunity. Žádný marketing, žádná reklama — jen novinky, maximálně jednou měsíčně.

+ button: "Potvrdit odběr" + expires: "Odkaz platí %hours% hodin. Pokud jste o odběr nežádali, tento e-mail klidně ignorujte a nic vám chodit nebude." diff --git a/translations/emails.de.yml b/translations/emails.de.yml index ae770619..7464fe9f 100644 --- a/translations/emails.de.yml +++ b/translations/emails.de.yml @@ -177,3 +177,12 @@ password_reset: button: "Neues Passwort wählen" expires: "Der Link funktioniert einmal und läuft nach %minutes% Minuten ab." not_requested: "Du hast das nicht angefordert? Dann kannst du diese E-Mail einfach ignorieren — dein Passwort bleibt unverändert." + +newsletter_confirmation: + subject: "Ein Klick und die Puzzle-Neuigkeiten gehören dir 🧩" + title: "Bestätige dein Abo" + content: | +

Hi! Jemand — hoffentlich du — hat darum gebeten, den MySpeedPuzzling-Newsletter an diese Adresse zu erhalten.

+

Bestätige das unten und wir sagen dir, was es auf der Plattform Neues gibt — neue Funktionen, Verbesserungen und Neuigkeiten aus der Community. Kein Marketing, keine Werbung — nur Neuigkeiten, höchstens einmal im Monat.

+ button: "Mein Abo bestätigen" + expires: "Der Link ist %hours% Stunden gültig. Wenn du das nicht angefordert hast, ignoriere diese E-Mail einfach — dann wird nichts verschickt." diff --git a/translations/emails.en.yml b/translations/emails.en.yml index b0966015..a18a7eb3 100644 --- a/translations/emails.en.yml +++ b/translations/emails.en.yml @@ -177,3 +177,12 @@ password_reset: button: "Choose a new password" expires: "The link works once and expires in %minutes% minutes." not_requested: "Did not request this? You can safely ignore this email — your password stays as it is." + +newsletter_confirmation: + subject: "One click and the puzzle news is yours 🧩" + title: "Confirm your subscription" + content: | +

Hi! Somebody — hopefully you — asked to receive the MySpeedPuzzling newsletter at this address.

+

Confirm below and we'll let you know what's new on the platform — new features, improvements and community news. No marketing, no promotions — just what's new, at most once a month.

+ button: "Confirm my subscription" + expires: "The link is valid for %hours% hours. If you didn't request this, simply ignore this e-mail and nothing will be sent." diff --git a/translations/emails.es.yml b/translations/emails.es.yml index 887c5511..ffa67a8f 100644 --- a/translations/emails.es.yml +++ b/translations/emails.es.yml @@ -177,3 +177,12 @@ password_reset: button: "Elegir una contraseña nueva" expires: "El enlace funciona una sola vez y caduca en %minutes% minutos." not_requested: "¿No lo has solicitado? Puedes ignorar este correo sin problema: tu contraseña seguirá siendo la misma." + +newsletter_confirmation: + subject: "Un clic y las novedades de puzzles son tuyas 🧩" + title: "Confirma tu suscripción" + content: | +

¡Hola! Alguien —esperamos que tú— ha pedido recibir el newsletter de MySpeedPuzzling en esta dirección.

+

Confírmalo abajo y te contaremos las novedades de la plataforma: nuevas funciones, mejoras y noticias de la comunidad. Sin marketing ni promociones — solo novedades, como mucho una vez al mes.

+ button: "Confirmar mi suscripción" + expires: "El enlace es válido durante %hours% horas. Si no lo has solicitado, ignora este correo sin problema y no te enviaremos nada." diff --git a/translations/emails.fr.yml b/translations/emails.fr.yml index d8c4f7ae..957f5917 100644 --- a/translations/emails.fr.yml +++ b/translations/emails.fr.yml @@ -177,3 +177,12 @@ password_reset: button: "Choisir un nouveau mot de passe" expires: "Le lien ne fonctionne qu'une seule fois et expire dans %minutes% minutes." not_requested: "Vous n'êtes pas à l'origine de cette demande ? Vous pouvez ignorer cet e-mail sans crainte — votre mot de passe reste inchangé." + +newsletter_confirmation: + subject: "Un clic et les nouveautés puzzle sont à vous 🧩" + title: "Confirmez votre abonnement" + content: | +

Bonjour ! Quelqu'un — nous espérons que c'est vous — a demandé à recevoir la newsletter MySpeedPuzzling à cette adresse.

+

Confirmez ci-dessous et nous vous tiendrons au courant des nouveautés de la plateforme — nouvelles fonctionnalités, améliorations et actualités de la communauté. Pas de marketing, pas de promotions — uniquement les nouveautés, pas plus d'une fois par mois.

+ button: "Confirmer mon abonnement" + expires: "Le lien est valable %hours% heures. Si vous n'êtes pas à l'origine de cette demande, ignorez simplement cet e-mail et rien ne vous sera envoyé." diff --git a/translations/emails.ja.yml b/translations/emails.ja.yml index 15bffa24..7d59db8f 100644 --- a/translations/emails.ja.yml +++ b/translations/emails.ja.yml @@ -177,3 +177,12 @@ password_reset: button: "新しいパスワードを設定する" expires: "リンクは1回のみ有効で、%minutes%分で期限切れになります。" not_requested: "お心当たりがない場合は、このメールは無視していただいて問題ありません。パスワードはそのまま変わりません。" + +newsletter_confirmation: + subject: "ワンクリックでパズルの最新情報があなたのものに 🧩" + title: "ニュースレターの登録を確認してください" + content: | +

こんにちは!どなたか — おそらくあなた — が、このアドレスでのMySpeedPuzzlingニュースレターの受信をリクエストしました。

+

下のボタンで確認すると、プラットフォームの最新情報 — 新機能、改善、コミュニティのニュース — をお届けします。マーケティングや宣伝は一切なし。多くても月1回だけです。

+ button: "登録を確認する" + expires: "リンクの有効期限は%hours%時間です。お心当たりがない場合は、このメールは無視していただいて問題ありません。今後何も届きません。" diff --git a/translations/messages.cs.yml b/translations/messages.cs.yml index 6cf5020b..3211c54d 100644 --- a/translations/messages.cs.yml +++ b/translations/messages.cs.yml @@ -715,7 +715,7 @@ "frequency_48_hours": "Každých 48 hodin" "frequency_1_week": "Jednou týdně" "newsletter_enabled": "Dostávat newsletter" - "newsletter_help": "Občas vám pošleme newsletter o novinkách na platformě, většinou o nových funkcích. Očekávaná frekvence: maximálně 1x měsíčně." + "newsletter_help": "Občas vám pošleme newsletter o novinkách na platformě, většinou o nových funkcích. Žádný marketing, žádná reklama — jen novinky. Očekávaná frekvence: maximálně 1x měsíčně." "messaging_notifications": "Zprávy a upozornění" "features_options": "Nastavení funkcí" "hide_ranking": "Skrýt mé hodnocení a dovednosti" @@ -2949,3 +2949,45 @@ auth: invalid: headline: "Tento odkaz pro obnovu hesla nefunguje" message: "Možná už byl použit, nebo ho nahradil novější odkaz pro obnovu hesla. Níže si vyžádejte nový." + +newsletter: + footer: + title: "📬 Co je nového na MySpeedPuzzling" + pitch: "Nové funkce a novinky z platformy, jednou měsíčně. Žádný marketing, žádná reklama." + email_placeholder: "Váš e-mail" + subscribe_button: "Odebírat" + consent: 'Přihlášením souhlasíte se zasíláním newsletteru MySpeedPuzzling. Odhlásit se můžete kdykoliv jedním kliknutím. Zásady ochrany soukromí' + manage_link: "Odběr newsletteru spravujete v nastavení notifikací" + flash: + try_again: "Něco se pokazilo, zkuste to prosím znovu." + invalid_email: "Zadejte prosím platnou e-mailovou adresu." + too_many_attempts: "Příliš mnoho pokusů, zkuste to prosím později." + check_inbox: + headline: "Ještě jeden krok — mrkněte do e-mailu" + sent: "Právě jsme vám poslali potvrzovací e-mail. Klikněte na odkaz v něm a newsletter je váš." + spam_hint: "Nic nedorazilo? Dejte tomu chvilku — a pro jistotu mrkněte i do spamu." + expires: "Potvrzovací odkaz platí %hours% hodin." + cta_homepage: "Prozkoumat MySpeedPuzzling" + confirm: + headline: "A je to! 🧩" + text: "Odběr je potvrzený. Příští newsletter MySpeedPuzzling vám přistane rovnou do schránky." + register_pitch: "Chcete víc než novinky? Založte si zdarma účet — měřte si časy skládání, budujte sbírku puzzlí a porovnávejte se s puzzlaři z celého světa." + cta_register: "Založit účet zdarma" + cta_homepage: "Prozkoumat MySpeedPuzzling" + unsubscribe: + headline: "Odhlásit odběr newsletteru?" + text: "Tímto přestane chodit newsletter MySpeedPuzzling na adresu %email%." + confirm_button: "Ano, odhlásit odběr" + manage_hint: "Radši méně e-mailů než žádné? V nastavení notifikací si můžete doladit, co vám posíláme." + manage_button: "Spravovat nastavení notifikací" + unsubscribed: + headline: "Odběr je odhlášený" + text: "Hotovo — žádné další newslettery od nás. Vaše schránka, vaše pravidla. ❤️" + resubscribe_player: 'Rozmysleli jste si to? Odběr si můžete kdykoliv znovu zapnout v nastavení notifikací.' + resubscribe_guest: "Rozmysleli jste si to? K odběru se můžete kdykoliv znovu přihlásit formulářem v patičce stránky." + cta_homepage: "Zpět na MySpeedPuzzling" + invalid: + headline: "Tento odkaz už nefunguje" + invalid_text: "Odkaz je neplatný nebo už neplatí. Pokud jste se chtěli přihlásit k odběru newsletteru, použijte formulář v patičce stránky." + expired_text: "Platnost potvrzovacího odkazu vypršela. Nic se neděje — přihlaste se znovu formulářem v patičce stránky a pošleme vám nový." + cta_homepage: "Zpět na MySpeedPuzzling" diff --git a/translations/messages.de.yml b/translations/messages.de.yml index 233350c0..ee64845c 100644 --- a/translations/messages.de.yml +++ b/translations/messages.de.yml @@ -695,7 +695,7 @@ "frequency_48_hours": "Alle 48 Stunden" "frequency_1_week": "Einmal pro Woche" "newsletter_enabled": "Newsletter erhalten" - "newsletter_help": "Wir senden gelegentlich einen Newsletter über Neuigkeiten auf der Plattform, meist über neue Funktionen. Erwartete Häufigkeit: maximal 1x pro Monat." + "newsletter_help": "Wir senden gelegentlich einen Newsletter über Neuigkeiten auf der Plattform, meist über neue Funktionen. Kein Marketing, keine Werbung — nur Neuigkeiten. Erwartete Häufigkeit: maximal 1x pro Monat." "messaging_notifications": "Nachrichten & Benachrichtigungen" "personal_access_tokens": "Persönliche Zugriffstoken" "personal_access_tokens_description": "Token für den Zugriff auf Ihre eigenen Daten über die API." @@ -2951,3 +2951,45 @@ auth: invalid: headline: "Dieser Link zum Zurücksetzen funktioniert nicht" message: "Er wurde möglicherweise bereits verwendet, oder ein neuerer Link zum Zurücksetzen hat ihn ersetzt. Fordern Sie unten einen neuen an." + +newsletter: + footer: + title: "📬 Was gibt's Neues auf MySpeedPuzzling" + pitch: "Neue Funktionen und Plattform-News, einmal im Monat. Kein Marketing, keine Werbung." + email_placeholder: "Ihre E-Mail-Adresse" + subscribe_button: "Abonnieren" + consent: 'Mit der Anmeldung stimmen Sie dem Erhalt des MySpeedPuzzling-Newsletters zu. Abmelden können Sie sich jederzeit mit einem Klick. Datenschutzerklärung' + manage_link: "Verwalten Sie Ihr Newsletter-Abo in den Benachrichtigungseinstellungen" + flash: + try_again: "Etwas ist schiefgelaufen, bitte versuchen Sie es erneut." + invalid_email: "Bitte geben Sie eine gültige E-Mail-Adresse ein." + too_many_attempts: "Zu viele Versuche, bitte versuchen Sie es später erneut." + check_inbox: + headline: "Noch ein Schritt – schauen Sie in Ihr Postfach" + sent: "Wir haben Ihnen gerade eine Bestätigungs-E-Mail geschickt. Klicken Sie auf den Link darin und der Newsletter gehört Ihnen." + spam_hint: "Nichts angekommen? Geben Sie ihr einen Moment – und werfen Sie zur Sicherheit auch einen Blick in den Spam-Ordner." + expires: "Der Bestätigungslink ist %hours% Stunden gültig." + cta_homepage: "MySpeedPuzzling entdecken" + confirm: + headline: "Sie sind dabei! 🧩" + text: "Ihr Abo ist bestätigt. Der nächste MySpeedPuzzling-Newsletter landet direkt in Ihrem Postfach." + register_pitch: "Lust auf mehr als Neuigkeiten? Erstellen Sie ein kostenloses Konto, um Ihre Legezeiten zu erfassen, Ihre Puzzle-Sammlung aufzubauen und sich mit Puzzlern aus aller Welt zu vergleichen." + cta_register: "Kostenloses Konto erstellen" + cta_homepage: "MySpeedPuzzling entdecken" + unsubscribe: + headline: "Newsletter abbestellen?" + text: "Damit erhält %email% keinen MySpeedPuzzling-Newsletter mehr." + confirm_button: "Ja, abmelden" + manage_hint: "Lieber weniger E-Mails statt gar keine? In den Benachrichtigungseinstellungen können Sie genau einstellen, was wir Ihnen schicken." + manage_button: "Benachrichtigungseinstellungen verwalten" + unsubscribed: + headline: "Sie sind abgemeldet" + text: "Erledigt – keine weiteren Newsletter von uns. Ihr Postfach, Ihre Regeln. ❤️" + resubscribe_player: 'Meinung geändert? Sie können den Newsletter jederzeit in Ihren Benachrichtigungseinstellungen wieder abonnieren.' + resubscribe_guest: "Meinung geändert? Über das Formular in der Fußzeile der Seite können Sie sich jederzeit wieder anmelden." + cta_homepage: "Zurück zu MySpeedPuzzling" + invalid: + headline: "Dieser Link funktioniert nicht mehr" + invalid_text: "Der Link ist ungültig oder nicht mehr gültig. Wenn Sie den Newsletter abonnieren wollten, nutzen Sie einfach das Formular in der Fußzeile der Seite." + expired_text: "Der Bestätigungslink ist abgelaufen. Kein Problem – melden Sie sich einfach erneut über das Formular in der Fußzeile an und wir schicken Ihnen einen neuen." + cta_homepage: "Zurück zu MySpeedPuzzling" diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 74cdf845..feeb919f 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -870,7 +870,7 @@ edit_profile: frequency_48_hours: "Every 48 hours" frequency_1_week: "Once a week" newsletter_enabled: "Receive newsletters" - newsletter_help: "We will occasionally send a newsletter about what's new on the platform, usually explaining new features. Expected frequency: no more than 1 per month." + newsletter_help: "We will occasionally send a newsletter about what's new on the platform, usually explaining new features. No marketing, no promotions — just what's new. Expected frequency: no more than 1 per month." personal_access_tokens: "Personal Access Tokens" personal_access_tokens_description: "Tokens for accessing your own data through the API." pat_created_title: "Your Personal Access Token" @@ -3238,3 +3238,45 @@ auth: invalid: headline: "This reset link does not work" message: "It may already have been used, or a newer reset link replaced it. Ask for a new one below." + +newsletter: + footer: + title: "📬 What's new on MySpeedPuzzling" + pitch: "New features and platform news, once a month. No marketing, no promotions." + email_placeholder: "Your e-mail address" + subscribe_button: "Subscribe" + consent: 'By subscribing you agree to receiving the MySpeedPuzzling newsletter. Unsubscribe anytime with one click. Privacy policy' + manage_link: "Manage your newsletter subscription in notification settings" + flash: + try_again: "Something went wrong, please try again." + invalid_email: "Please enter a valid e-mail address." + too_many_attempts: "Too many attempts, please try again later." + check_inbox: + headline: "One more step — check your inbox" + sent: "We've just sent you a confirmation e-mail. Click the link inside and the newsletter is yours." + spam_hint: "Nothing there? Give it a minute — and maybe peek into the spam folder, just in case." + expires: "The confirmation link is valid for %hours% hours." + cta_homepage: "Explore MySpeedPuzzling" + confirm: + headline: "You're in! 🧩" + text: "Your subscription is confirmed. The next MySpeedPuzzling newsletter will land right in your inbox." + register_pitch: "Want more than news? Create a free account to track your solving times, build your puzzle collection and compare with puzzlers worldwide." + cta_register: "Create a free account" + cta_homepage: "Explore MySpeedPuzzling" + unsubscribe: + headline: "Unsubscribe from the newsletter?" + text: "This will stop the MySpeedPuzzling newsletter for %email%." + confirm_button: "Yes, unsubscribe me" + manage_hint: "Prefer fewer e-mails instead of none? You can fine-tune what we send you in your notification settings." + manage_button: "Manage my notification settings" + unsubscribed: + headline: "You're unsubscribed" + text: "Done — no more newsletters from us. Your inbox, your rules. ❤️" + resubscribe_player: 'Changed your mind? You can re-subscribe anytime in your notification settings.' + resubscribe_guest: "Changed your mind? You can subscribe again anytime using the form in the page footer." + cta_homepage: "Back to MySpeedPuzzling" + invalid: + headline: "This link doesn't work anymore" + invalid_text: "The link is invalid or no longer valid. If you wanted to subscribe to the newsletter, just use the form in the page footer." + expired_text: "The confirmation link has expired. No worries — subscribe again using the form in the page footer and we'll send you a fresh one." + cta_homepage: "Back to MySpeedPuzzling" diff --git a/translations/messages.es.yml b/translations/messages.es.yml index 4810ca79..77c0b983 100644 --- a/translations/messages.es.yml +++ b/translations/messages.es.yml @@ -460,7 +460,7 @@ "frequency_48_hours": "Cada 48 horas" "frequency_1_week": "Una vez a la semana" "newsletter_enabled": "Recibir boletines" - "newsletter_help": "Ocasionalmente enviaremos un boletín sobre novedades en la plataforma, generalmente explicando nuevas funciones. Frecuencia esperada: no más de 1 al mes." + "newsletter_help": "Ocasionalmente enviaremos un boletín sobre novedades en la plataforma, generalmente explicando nuevas funciones. Sin marketing ni promociones: solo novedades. Frecuencia esperada: no más de 1 al mes." "features_options": "Opciones de funciones" "hide_ranking": "Ocultar mi clasificación y habilidad" "hide_ranking_help": "Tu nivel de habilidad y clasificación Rating se ocultarán. No aparecerás en la escalera MSP Rating. Tus datos seguirán usándose en los cálculos de dificultad de puzzles y clasificaciones de otros jugadores." @@ -2951,3 +2951,45 @@ fair_use_policy: "invalid": "headline": "Este enlace de restablecimiento no funciona" "message": "Puede que ya se haya usado, o que un enlace de restablecimiento más nuevo lo haya sustituido. Solicita uno nuevo aquí abajo." + +newsletter: + footer: + title: "📬 Novedades de MySpeedPuzzling" + pitch: "Nuevas funciones y novedades de la plataforma, una vez al mes. Sin marketing ni promociones." + email_placeholder: "Tu dirección de correo electrónico" + subscribe_button: "Suscribirme" + consent: 'Al suscribirte aceptas recibir el newsletter de MySpeedPuzzling. Puedes darte de baja en cualquier momento con un solo clic. Política de privacidad' + manage_link: "Gestiona tu suscripción al newsletter en la configuración de notificaciones" + flash: + try_again: "Algo ha salido mal. Por favor, inténtalo de nuevo." + invalid_email: "Por favor, introduce una dirección de correo electrónico válida." + too_many_attempts: "Demasiados intentos. Por favor, inténtalo de nuevo más tarde." + check_inbox: + headline: "Un paso más: revisa tu bandeja de entrada" + sent: "Acabamos de enviarte un correo de confirmación. Haz clic en el enlace y el newsletter es tuyo." + spam_hint: "¿No ha llegado nada? Dale un minuto y, por si acaso, echa también un vistazo a la carpeta de spam." + expires: "El enlace de confirmación es válido durante %hours% horas." + cta_homepage: "Explorar MySpeedPuzzling" + confirm: + headline: "¡Ya estás dentro! 🧩" + text: "Tu suscripción está confirmada. El próximo newsletter de MySpeedPuzzling llegará directo a tu bandeja de entrada." + register_pitch: "¿Quieres algo más que novedades? Crea una cuenta gratis para registrar tus tiempos de resolución, construir tu colección de puzzles y compararte con puzzleros de todo el mundo." + cta_register: "Crear una cuenta gratis" + cta_homepage: "Explorar MySpeedPuzzling" + unsubscribe: + headline: "¿Darte de baja del newsletter?" + text: "Con esto dejará de llegar el newsletter de MySpeedPuzzling a %email%." + confirm_button: "Sí, darme de baja" + manage_hint: "¿Prefieres menos correos en lugar de ninguno? Puedes ajustar lo que te enviamos en tu configuración de notificaciones." + manage_button: "Gestionar mi configuración de notificaciones" + unsubscribed: + headline: "Te has dado de baja" + text: "Hecho: no recibirás más newsletters de nuestra parte. Tu bandeja de entrada, tus reglas. ❤️" + resubscribe_player: '¿Has cambiado de opinión? Puedes volver a suscribirte en cualquier momento desde tu configuración de notificaciones.' + resubscribe_guest: "¿Has cambiado de opinión? Puedes volver a suscribirte en cualquier momento con el formulario del pie de página." + cta_homepage: "Volver a MySpeedPuzzling" + invalid: + headline: "Este enlace ya no funciona" + invalid_text: "El enlace no es válido o ya ha caducado. Si querías suscribirte al newsletter, usa el formulario del pie de página." + expired_text: "El enlace de confirmación ha caducado. No pasa nada: suscríbete de nuevo con el formulario del pie de página y te enviaremos uno nuevo." + cta_homepage: "Volver a MySpeedPuzzling" diff --git a/translations/messages.fr.yml b/translations/messages.fr.yml index 0b3b7706..4644b32a 100644 --- a/translations/messages.fr.yml +++ b/translations/messages.fr.yml @@ -698,7 +698,7 @@ "frequency_48_hours": "Toutes les 48 heures" "frequency_1_week": "Une fois par semaine" "newsletter_enabled": "Recevoir les newsletters" - "newsletter_help": "Nous enverrons occasionnellement une newsletter sur les nouveautés de la plateforme, généralement pour expliquer les nouvelles fonctionnalités. Fréquence prévue : pas plus d'une fois par mois." + "newsletter_help": "Nous enverrons occasionnellement une newsletter sur les nouveautés de la plateforme, généralement pour expliquer les nouvelles fonctionnalités. Pas de marketing, pas de promotions — uniquement les nouveautés. Fréquence prévue : pas plus d'une fois par mois." "messaging_notifications": "Messagerie et notifications" "personal_access_tokens": "Jetons d'accès personnels" "personal_access_tokens_description": "Jetons pour accéder à vos propres données via l'API." @@ -2953,3 +2953,45 @@ edition: "invalid": "headline": "Ce lien de réinitialisation ne fonctionne pas" "message": "Il a peut-être déjà été utilisé, ou un lien de réinitialisation plus récent l'a remplacé. Demandez-en un nouveau ci-dessous." + +newsletter: + footer: + title: "📬 Les nouveautés de MySpeedPuzzling" + pitch: "Nouvelles fonctionnalités et actualités de la plateforme, une fois par mois. Pas de marketing, pas de promotions." + email_placeholder: "Votre adresse e-mail" + subscribe_button: "S'abonner" + consent: 'En vous abonnant, vous acceptez de recevoir la newsletter MySpeedPuzzling. Désabonnez-vous à tout moment en un clic. Politique de confidentialité' + manage_link: "Gérez votre abonnement à la newsletter dans les paramètres de notifications" + flash: + try_again: "Une erreur est survenue, veuillez réessayer." + invalid_email: "Veuillez saisir une adresse e-mail valide." + too_many_attempts: "Trop de tentatives, veuillez réessayer plus tard." + check_inbox: + headline: "Plus qu'une étape — vérifiez votre boîte de réception" + sent: "Nous venons de vous envoyer un e-mail de confirmation. Cliquez sur le lien qu'il contient et la newsletter est à vous." + spam_hint: "Rien reçu ? Patientez une minute — et jetez un œil dans vos spams, au cas où." + expires: "Le lien de confirmation est valable %hours% heures." + cta_homepage: "Explorer MySpeedPuzzling" + confirm: + headline: "Ça y est ! 🧩" + text: "Votre abonnement est confirmé. La prochaine newsletter MySpeedPuzzling arrivera directement dans votre boîte de réception." + register_pitch: "Envie de plus que des nouveautés ? Créez un compte gratuit pour suivre vos temps de résolution, constituer votre collection de puzzles et vous comparer aux puzzleurs du monde entier." + cta_register: "Créer un compte gratuit" + cta_homepage: "Explorer MySpeedPuzzling" + unsubscribe: + headline: "Se désabonner de la newsletter ?" + text: "La newsletter MySpeedPuzzling ne sera plus envoyée à %email%." + confirm_button: "Oui, me désabonner" + manage_hint: "Vous préférez moins d'e-mails plutôt qu'aucun ? Vous pouvez ajuster ce que nous vous envoyons dans vos paramètres de notifications." + manage_button: "Gérer mes paramètres de notifications" + unsubscribed: + headline: "Vous êtes désabonné" + text: "C'est fait — plus aucune newsletter de notre part. Votre boîte de réception, vos règles. ❤️" + resubscribe_player: 'Vous avez changé d''avis ? Vous pouvez vous réabonner à tout moment dans vos paramètres de notifications.' + resubscribe_guest: "Vous avez changé d'avis ? Vous pouvez vous réabonner à tout moment grâce au formulaire en bas de page." + cta_homepage: "Retour à MySpeedPuzzling" + invalid: + headline: "Ce lien ne fonctionne plus" + invalid_text: "Le lien est invalide ou n'est plus valable. Si vous vouliez vous abonner à la newsletter, utilisez simplement le formulaire en bas de page." + expired_text: "Le lien de confirmation a expiré. Pas d'inquiétude — réabonnez-vous grâce au formulaire en bas de page et nous vous en enverrons un nouveau." + cta_homepage: "Retour à MySpeedPuzzling" diff --git a/translations/messages.ja.yml b/translations/messages.ja.yml index ea1f0424..6dcf0357 100644 --- a/translations/messages.ja.yml +++ b/translations/messages.ja.yml @@ -803,7 +803,7 @@ "frequency_48_hours": "48時間ごと" "frequency_1_week": "週に1回" "newsletter_enabled": "ニュースレターを受信する" - "newsletter_help": "プラットフォームの新機能などについてのニュースレターを不定期にお送りします。想定頻度:月1回以下。" + "newsletter_help": "プラットフォームの新機能などについてのニュースレターを不定期にお送りします。マーケティングや宣伝は一切ありません。想定頻度:月1回以下。" "messaging_notifications": "メッセージと通知" "personal_access_tokens": "個人アクセストークン" "personal_access_tokens_description": "API経由で自分のデータにアクセスするためのトークン。" @@ -2940,3 +2940,45 @@ qr_code: "invalid": "headline": "このリセットリンクは使用できません" "message": "すでに使用済みか、新しいリセットリンクに置き換えられた可能性があります。下記から新しいリンクをリクエストしてください。" + +newsletter: + footer: + title: "📬 MySpeedPuzzling の新着情報" + pitch: "新機能とプラットフォームの最新情報を月1回お届けします。マーケティングや宣伝はありません。" + email_placeholder: "メールアドレス" + subscribe_button: "登録する" + consent: '登録すると、MySpeedPuzzlingニュースレターの受信に同意したことになります。配信停止はいつでもワンクリックで可能です。プライバシーポリシー' + manage_link: "ニュースレターの配信は通知設定で管理できます" + flash: + try_again: "問題が発生しました。もう一度お試しください。" + invalid_email: "有効なメールアドレスを入力してください。" + too_many_attempts: "試行回数が多すぎます。しばらくしてからもう一度お試しください。" + check_inbox: + headline: "あと一歩です — メールをご確認ください" + sent: "確認メールをお送りしました。メール内のリンクをクリックすると、ニュースレターの登録が完了します。" + spam_hint: "届いていませんか?もう少しお待ちください — 念のため迷惑メールフォルダもご確認ください。" + expires: "確認リンクの有効期限は%hours%時間です。" + cta_homepage: "MySpeedPuzzlingを見てみる" + confirm: + headline: "登録が完了しました!🧩" + text: "登録が確認されました。次回のMySpeedPuzzlingニュースレターは、受信トレイに直接お届けします。" + register_pitch: "ニュースだけでは物足りませんか?無料アカウントを作成すると、タイムの記録、パズルコレクションの管理、世界中のパズラーとの比較ができます。" + cta_register: "無料アカウントを作成" + cta_homepage: "MySpeedPuzzlingを見てみる" + unsubscribe: + headline: "ニュースレターの配信を停止しますか?" + text: "%email%宛てのMySpeedPuzzlingニュースレターの配信を停止します。" + confirm_button: "はい、配信を停止する" + manage_hint: "すべて停止するのではなく、メールを減らすだけにしませんか?通知設定で、お送りする内容を細かく調整できます。" + manage_button: "通知設定を管理する" + unsubscribed: + headline: "配信を停止しました" + text: "完了です — 今後、私たちからニュースレターが届くことはありません。あなたの受信トレイは、あなたのルールで。❤️" + resubscribe_player: '気が変わりましたか?通知設定からいつでも配信を再開できます。' + resubscribe_guest: "気が変わりましたか?ページ下部のフォームからいつでも再登録できます。" + cta_homepage: "MySpeedPuzzlingに戻る" + invalid: + headline: "このリンクはもう使用できません" + invalid_text: "このリンクは無効か、有効期限が切れています。ニュースレターの登録をご希望の場合は、ページ下部のフォームをご利用ください。" + expired_text: "確認リンクの有効期限が切れています。ご心配なく — ページ下部のフォームからもう一度登録していただければ、新しいリンクをお送りします。" + cta_homepage: "MySpeedPuzzlingに戻る"