Problem
PR #618 / issue #573 added an in-app notification inbox (GET /auth/notifications + POST /auth/notifications/seen) so users can see when an admin has approved/rejected/revoked their access request. That covers users who happen to be in the app. It does not reach users who submitted a request and aren't actively polling — which is most users most of the time.
EmailVerificationHandler currently delegates account-verification email to Zitadel, which has its own SMTP. The application itself has no outbound mail wiring of its own, so it cannot send transactional notifications (access-request reviews, future events) from its own templates and brand.
Adding application-owned email delivery unlocks the natural channel for this class of low-frequency, async, "your thing happened" event — and lays the foundation for any future user-targeted notification (decree published, missal edition added, etc.).
Proposed contract
Email delivery layer
- Symfony Mailer with
MAILER_DSN env (defaults to smtp://mailpit:1025 in dev — mailpit is already in docker-compose.yml).
- Symfony Messenger for async dispatch with Doctrine transport (rows in a
mail_outbox table). Admin clicks on /admin/access-requests/{id}/{action} enqueue a message; a worker drains it. Admin UX stays snappy; retries + dead-lettering are free.
- Per-locale
.po templates (access_request_approved.subject.{locale}, …body.{locale}, etc.) — same gettext machinery the rest of the API uses.
User preferences
Two schema options:
Option A (preferred): bit-flag column on user_email_preferences
A single user_email_preferences(user_id PK, subscriptions INT NOT NULL DEFAULT <all-on>, updated_at TIMESTAMP NOT NULL DEFAULT NOW()) table.
subscriptions is a bit set:
bit 0 (1) access_request_reviewed — approve/reject/revoke notifications
bit 1 (2) account_security_event — login from new device, password change, etc. (future)
bit 2 (4) content_update — new decree, new missal edition (future)
…
Default value (e.g. 7 = bits 0-2 all on) means new users are opted-in to transactional categories. Users disable categories individually via PATCH /auth/preferences/email. Adding a new event type = adding a constant in code; no migration.
Option B: row-per-(user, event_type) table
user_email_preferences(user_id, event_type VARCHAR(64), enabled BOOL, …) — one row per subscription. More flexible (per-event frequency, delivery channel, etc. become extra columns) but heavier and JOIN-required.
Recommend A for v1; migrate to B if/when per-event metadata (frequency, channel, threshold) becomes a real need.
Endpoints
GET /auth/preferences/email
Returns the current user's preferences plus the static catalog of event types and their bit values (so the frontend can render checkboxes without hard-coding):
{
\"subscriptions\": 7,
\"events\": [
{ \"key\": \"access_request_reviewed\", \"bit\": 1, \"description\": \"...\" },
{ \"key\": \"account_security_event\", \"bit\": 2, \"description\": \"...\" },
{ \"key\": \"content_update\", \"bit\": 4, \"description\": \"...\" }
]
}
PATCH /auth/preferences/email
(or, friendlier: { \"enable\": [\"access_request_reviewed\"], \"disable\": [\"content_update\"] } — the backend translates to the bitmask). Returns the new state.
Trigger point
In AccessRequestAdminHandler::handleApprove/Reject/Revoke, after the DB write commits, enqueue a Messenger message with (user_id, user_email, locale, event_type, request_id, status, review_notes, permissions). The worker:
- Re-reads
user_email_preferences.subscriptions; bails (idempotently) if the user has opted out.
- Loads the per-locale template.
- Sends via Symfony Mailer.
- Records
mail_outbox.status='sent', sent_at=NOW().
Idempotency
access_requests gets a last_review_email_sent_at TIMESTAMP NULL column (or a separate (request_id, kind) log). Worker skips if already sent for this (request_id, status) tuple. Re-clicking Approve in the admin UI doesn't double-send.
Schema
CREATE TABLE user_email_preferences (
user_id VARCHAR(255) PRIMARY KEY,
subscriptions INTEGER NOT NULL DEFAULT 7,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE mail_outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(255) NOT NULL,
recipient VARCHAR(255) NOT NULL,
locale VARCHAR(10) NOT NULL,
template_key VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMP,
CONSTRAINT chk_mail_outbox_status CHECK (status IN ('pending','sending','sent','failed','dead'))
);
CREATE INDEX idx_mail_outbox_pending ON mail_outbox (created_at) WHERE status = 'pending';
access_requests gets one new column:
ALTER TABLE access_requests
ADD COLUMN last_review_email_sent_at TIMESTAMP;
OpenAPI additions
EmailPreferences schema (subscriptions int + events catalog)
UpdateEmailPreferencesBody
- Paths:
GET /auth/preferences/email, PATCH /auth/preferences/email
- Both gated by
BearerAuth | CookieAuth (no admin role required — user manages their own)
Worker / ops
- New
litcal-mail-worker container in docker-compose.yml running bin/console messenger:consume async -vv --time-limit=3600.
- Production: same process, supervised by systemd or whatever the deploy target uses.
- Mailpit (already in compose) catches dev mail and exposes a web UI.
Default values + opt-out
Default subscriptions = 7 (all transactional categories on). Users can disable categories individually. No marketing / promotional mail — only transactional.
A future "unsubscribe one-click" link in every email (RFC 8058 List-Unsubscribe-Post) lets users opt out without logging in.
Out of scope (for this issue)
- SMS / push channels — email only.
- Digest / batched delivery — every event sends immediately. Add frequency knobs later if users complain.
- Bounce processing (parsing DSNs, marking addresses dead). Symfony Mailer's transport will log failures; manual investigation for v1.
- HTML templates with images / tracking pixels — plain-text only initially.
- Inbound email (replying to a notification) — out of scope; the email links back to the access-requests page.
Frontend tracking
A corresponding LiturgicalCalendarFrontend issue will track the preferences UI (checkboxes bound to GET/PATCH /auth/preferences/email). Gated on this one landing first.
Related
This issue is the natural follow-up that closes the push channel.
🤖 Generated with Claude Code
Problem
PR #618 / issue #573 added an in-app notification inbox (
GET /auth/notifications+POST /auth/notifications/seen) so users can see when an admin has approved/rejected/revoked their access request. That covers users who happen to be in the app. It does not reach users who submitted a request and aren't actively polling — which is most users most of the time.EmailVerificationHandlercurrently delegates account-verification email to Zitadel, which has its own SMTP. The application itself has no outbound mail wiring of its own, so it cannot send transactional notifications (access-request reviews, future events) from its own templates and brand.Adding application-owned email delivery unlocks the natural channel for this class of low-frequency, async, "your thing happened" event — and lays the foundation for any future user-targeted notification (decree published, missal edition added, etc.).
Proposed contract
Email delivery layer
MAILER_DSNenv (defaults tosmtp://mailpit:1025in dev — mailpit is already indocker-compose.yml).mail_outboxtable). Admin clicks on/admin/access-requests/{id}/{action}enqueue a message; a worker drains it. Admin UX stays snappy; retries + dead-lettering are free..potemplates (access_request_approved.subject.{locale},…body.{locale}, etc.) — same gettext machinery the rest of the API uses.User preferences
Two schema options:
Option A (preferred): bit-flag column on
user_email_preferencesA single
user_email_preferences(user_id PK, subscriptions INT NOT NULL DEFAULT <all-on>, updated_at TIMESTAMP NOT NULL DEFAULT NOW())table.subscriptionsis a bit set:Default value (e.g.
7= bits 0-2 all on) means new users are opted-in to transactional categories. Users disable categories individually viaPATCH /auth/preferences/email. Adding a new event type = adding a constant in code; no migration.Option B: row-per-(user, event_type) table
user_email_preferences(user_id, event_type VARCHAR(64), enabled BOOL, …)— one row per subscription. More flexible (per-event frequency, delivery channel, etc. become extra columns) but heavier and JOIN-required.Recommend A for v1; migrate to B if/when per-event metadata (frequency, channel, threshold) becomes a real need.
Endpoints
GET /auth/preferences/emailReturns the current user's preferences plus the static catalog of event types and their bit values (so the frontend can render checkboxes without hard-coding):
{ \"subscriptions\": 7, \"events\": [ { \"key\": \"access_request_reviewed\", \"bit\": 1, \"description\": \"...\" }, { \"key\": \"account_security_event\", \"bit\": 2, \"description\": \"...\" }, { \"key\": \"content_update\", \"bit\": 4, \"description\": \"...\" } ] }PATCH /auth/preferences/email{ \"subscriptions\": 5 }(or, friendlier:
{ \"enable\": [\"access_request_reviewed\"], \"disable\": [\"content_update\"] }— the backend translates to the bitmask). Returns the new state.Trigger point
In
AccessRequestAdminHandler::handleApprove/Reject/Revoke, after the DB write commits, enqueue a Messenger message with(user_id, user_email, locale, event_type, request_id, status, review_notes, permissions). The worker:user_email_preferences.subscriptions; bails (idempotently) if the user has opted out.mail_outbox.status='sent', sent_at=NOW().Idempotency
access_requestsgets alast_review_email_sent_at TIMESTAMP NULLcolumn (or a separate(request_id, kind)log). Worker skips if already sent for this(request_id, status)tuple. Re-clicking Approve in the admin UI doesn't double-send.Schema
access_requestsgets one new column:OpenAPI additions
EmailPreferencesschema (subscriptions int + events catalog)UpdateEmailPreferencesBodyGET /auth/preferences/email,PATCH /auth/preferences/emailBearerAuth | CookieAuth(no admin role required — user manages their own)Worker / ops
litcal-mail-workercontainer indocker-compose.ymlrunningbin/console messenger:consume async -vv --time-limit=3600.Default values + opt-out
Default
subscriptions = 7(all transactional categories on). Users can disable categories individually. No marketing / promotional mail — only transactional.A future "unsubscribe one-click" link in every email (RFC 8058
List-Unsubscribe-Post) lets users opt out without logging in.Out of scope (for this issue)
Frontend tracking
A corresponding LiturgicalCalendarFrontend issue will track the preferences UI (checkboxes bound to
GET/PATCH /auth/preferences/email). Gated on this one landing first.Related
This issue is the natural follow-up that closes the push channel.
🤖 Generated with Claude Code