Skip to content

User-facing email notifications for access-request reviews (and beyond) #621

Description

@JohnRDOrazio

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

{ \"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:

  1. Re-reads user_email_preferences.subscriptions; bails (idempotently) if the user has opted out.
  2. Loads the per-locale template.
  3. Sends via Symfony Mailer.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementphpPull requests that update php code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions