Docs/Emails & Subscriptions

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

ComponentFileRole
EmailServicesrc/Service/EmailService.phpTwig + AWS SES wrapper. Applies realm branding, tags marketing sends with a campaign value, swallows failures and returns a bool.
EmailSubscriptionsrc/Entity/EmailSubscription.phpOne 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.
SubscriptionServicesrc/Service/SubscriptionService.phpOwns 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.

ChannelPurposeCadence
NewsKATFORGE Studios newsletter, blog posts, product announcementsAs-published
LextrisLextris game updates, new features, launch eventsAs-published
Gear GoblinsGear Goblins updates, new content, beta invitesAs-published
StumperDaily 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.

TemplateBrandTriggerPurpose
user-verificationStudiosUserRegisteredEvent on signupVerify new user's email
welcomeStudiosVerification successOnboarding next steps
guest-upgradedStudiosGuest → registered upgradeWelcome the newly-registered user
account-deletedStudiosDELETE /v1/users/@meConfirm soft-delete + grace-period behavior
password-resetStudiosPOST /v1/gateway/reset-password/requestOne-time reset link
password-changedStudiosPassword successfully changedPaper trail for compromised-credential incidents
login-codeStudiosPOST /v1/gateway/code/requestPasswordless login code
security-alertStudiosLoginGuard flags new IP / device / failed burstAlert on suspicious login
account-lockedStudiosAuto-lock after repeated failed loginsExplain the lock and how to recover
oauth-linkedStudiosDiscord / Google / Apple / Steam linkedConfirm provider linked
oauth-unlinkedStudiosProvider unlinkedConfirm provider removed
subscription-confirmationStudiosPOST /v1/subscribeDeliver the verification link
internal/new-subscriberInternal (kat@katforge.com)First-time subscriberInternal ping for tracking
daily-stumper-reminderStumperstumper:reminder:send (cron, hourly)Nudge subscribers; skipped if already played today
marketing/lextris-launchLextrisManual campaign sendAnnounce Lextris launch to opted-in subscribers
contact-receiptStudiosPOST /v1/leads/contactInternal receipt for contact-form submissions

Managing preferences

SurfaceCallSemantics
Logged-in UI/account/privacyRenders a checkbox per channel; toggles write through the SDK
Logged in, single channelkatforge.me.subscribe ('Stumper')Idempotent merge
Logged in, single channelkatforge.me.unsubscribe ('Stumper')Idempotent removal
Logged in, full setkatforge.me.setChannels ([ 'News', 'Stumper' ])Replace
Guestkatforge.leads.subscribe (email, channels, { timezone })Sends verification; nothing fires until confirmed. Timezone drives bucketed sends like the daily Stumper reminder.
Any email footerToken-signed GET /v1/unsubscribeOne-click: flips is_active = false across every channel

CLI

All commands run via php bin/console inside the api container.

CommandPurpose
stumper:reminder:sendFire daily Stumper reminders. Cron hourly; per-TZ 9am bucket.
debug:routerList every route — useful when wiring a new email trigger.
listEnumerate every registered console command.

stumper:reminder:send

OptionDescription
--dry-runList recipients without sending
--hour=<0-23>Override the 9am target hour
--forceIgnore 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

  1. Drop the template at templates/<name>.html.twig, extending email-base.html.twig.
  2. Call $this->emailService->send (...) from the controller, service, or event subscriber that triggers it.
  3. If opt-in, add a case to src/Channel.php and gate on EmailSubscription.channels before sending.
  4. Add a row to the Transactional emails table so the inventory stays honest.

New channel

  1. Add case foo = 'Foo'; to src/Channel.php.
  2. Add a checkbox row to katforge.com/pages/account/privacy.vue.
  3. Add a row to the Channels table.
  4. Ship the send path (immediate trigger or scheduled command).