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 @@
+
+
+
+
+
+
+
+
+
|
+
{{ '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 #} + + {% else %} + + {% endif %} +{{ 'newsletter_confirmation.expires'|trans({'%hours%': expiresHours}) }}
+{{ 'newsletter.check_inbox.spam_hint'|trans }}
+ +{{ 'newsletter.check_inbox.expires'|trans({'%hours%': expiresHours}) }}
+ + +
+ {{ '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 %} +
+
+
+ {{ 'newsletter.unsubscribe.text'|trans({'%email%': email})|raw }}
+ + + + {% if isPlayer %} +{{ 'newsletter.unsubscribe.manage_hint'|trans }}
+ + {% endif %} +
+ + {% if isPlayer %} + {{ 'newsletter.unsubscribed.resubscribe_player'|trans({'%settingsUrl%': path('edit_profile')})|raw }} + {% else %} + {{ 'newsletter.unsubscribed.resubscribe_guest'|trans }} + {% endif %} +
+ + +
+ 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に戻る"