Emails & Subscriptions
Every email leaving api.katforge.com goes through one service, renders from one template base, and carries a one-click unsubscribe when applicable.
Two kinds of email leave the API: transactional (verification, password reset, security alerts — sent in response to a user action) and opt-in (newsletters, game updates, daily reminders — gated on an explicit channel subscription).
Architecture
| Component | File | Role |
|---|---|---|
EmailService | src/Service/EmailService.php | Twig + AWS SES wrapper. Applies realm branding, tags marketing sends with a campaign value, swallows failures and returns a bool. |
EmailSubscription | src/Entity/EmailSubscription.php | One row per address. Tracks channels[], verified_at, consent* (consent record retained to comply with CAN-SPAM, the U.S. anti-spam regulation), is_active/opted_out_at, and optional IANA timezone. |
SubscriptionService | src/Service/SubscriptionService.php | Owns the subscribe → verify → unsubscribe lifecycle. subscribe() merges idempotently and fires the confirmation email; verify() flips verified_at; unsubscribe() flags the row inactive. |
Channels
A channel is a string in EmailSubscription.channels[]. Authoritative enum: src/Channel.php.
| Channel | Purpose | Cadence |
|---|---|---|
News | As-published | |
Lextris | Lextris game updates, new features, launch events | As-published |
Gear Goblins | Gear Goblins updates, new content, beta invites | As-published |
Stumper | Daily challenge reminder (shuffle + gauntlet) | Daily, 9am in the subscriber's IANA timezone (collected during signup; see Identity) — skipped on days the recipient already played |
Transactional emails
Every template lives under api.katforge.com/templates/, extending email-base.html.twig.
| Template | Brand | Trigger | Purpose |
|---|---|---|---|
user-verification | Studios | UserRegisteredEvent on signup | Verify new user's email |
welcome | Studios | Verification success | Onboarding next steps |
guest-upgraded | Studios | Guest → registered upgrade | Welcome the newly-registered user |
account-deleted | Studios | DELETE /v1/users/@me | Confirm soft-delete + grace-period behavior |
password-reset | Studios | POST /v1/gateway/reset-password/request | One-time reset link |
password-changed | Studios | Password successfully changed | Paper trail for compromised-credential incidents |
login-code | Studios | POST /v1/gateway/code/request | Passwordless login code |
security-alert | Studios | LoginGuard flags new IP / device / failed burst | Alert on suspicious login |
account-locked | Studios | Auto-lock after repeated failed logins | Explain the lock and how to recover |
oauth-linked | Studios | Discord / Google / Apple / Steam linked | Confirm provider linked |
oauth-unlinked | Studios | Provider unlinked | Confirm provider removed |
subscription-confirmation | Studios | POST /v1/subscribe | Deliver the verification link |
internal/new-subscriber | Internal (kat@katforge.com) | First-time subscriber | Internal ping for tracking |
daily-stumper-reminder | Stumper | stumper:reminder:send (cron, hourly) | Nudge subscribers; skipped if already played today |
marketing/lextris-launch | Lextris | Manual campaign send | Announce Lextris launch to opted-in subscribers |
contact-receipt | Studios | POST /v1/leads/contact | Internal receipt for contact-form submissions |
Managing preferences
| Surface | Call | Semantics |
|---|---|---|
| Logged-in UI | /account/privacy | Renders a checkbox per channel; toggles write through the SDK |
| Logged in, single channel | katforge.me.subscribe ('Stumper') | Idempotent merge |
| Logged in, single channel | katforge.me.unsubscribe ('Stumper') | Idempotent removal |
| Logged in, full set | katforge.me.setChannels ([ 'News', 'Stumper' ]) | Replace |
| Guest | katforge.leads.subscribe (email, channels, { timezone }) | Sends verification; nothing fires until confirmed. Timezone drives bucketed sends like the daily Stumper reminder. |
| Any email footer | Token-signed GET /v1/unsubscribe | One-click: flips is_active = false across every channel |
CLI
All commands run via php bin/console inside the api container.
| Command | Purpose |
|---|---|
stumper:reminder:send | Fire daily Stumper reminders. Cron hourly; per-TZ 9am bucket. |
debug:router | List every route — useful when wiring a new email trigger. |
list | Enumerate every registered console command. |
stumper:reminder:send
| Option | Description |
|---|---|
--dry-run | List recipients without sending |
--hour=<0-23> | Override the 9am target hour |
--force | Ignore hour match; send to every eligible subscriber |
--email=<addr> | Target a single address (implies --force) |
shell
php bin/console stumper:reminder:send
shell
php bin/console stumper:reminder:send --email=you@example.com --dry-run
For developers
New transactional email
- Drop the template at
templates/<name>.html.twig, extendingemail-base.html.twig. - Call
$this->emailService->send (...)from the controller, service, or event subscriber that triggers it. - If opt-in, add a case to
src/Channel.phpand gate onEmailSubscription.channelsbefore sending. - Add a row to the Transactional emails table so the inventory stays honest.
New channel
- Add
case foo = 'Foo';tosrc/Channel.php. - Add a checkbox row to
katforge.com/pages/account/privacy.vue. - Add a row to the Channels table.
- Ship the send path (immediate trigger or scheduled command).