Admin Email API
Two admin-only endpoints under /admin/settings diagnose SMTP regressions without a manual psql query against email_deliveries:
- GET /api/v1/admin/email/deliveries/health
- POST /api/v1/admin/email/test
Both endpoints live under the existing admin:settings scope.
Authentication
- Bearer JWT required. Anonymous returns 401.
- Caller must be a super-admin or hold the admin:settings grant.
- Non-admin returns 403 with the missing scope in the body.
Rate limits
Two layers apply:
- Standard RateScope budget: api.read for health, api.write for test. Configurable per user group via the rate-limit config.
- Per-admin floor on the test endpoint: 1 request per 10 seconds per actor. Independent of the group budget. A second call within the cooldown returns 429 with a Retry-After header (in seconds).
GET deliveries health
Path: GET /api/v1/admin/email/deliveries/health
Returns one summary row per distinct template_slug seen in email_deliveries within the last 30 days. Used by the admin email settings page to surface lines like "the verification template has 17 failures in the last 24h with last_error = 535 5.7.8" without a manual psql round-trip.
Response shape (200):
- window_start: ISO8601 RFC3339 lower bound of the 30-day window.
- generated_at: ISO8601 RFC3339 when the snapshot was computed.
- templates: array of per-slug rows with these fields:
- template_slug
- latest_status, latest_created_at, latest_sent_at, latest_attempts, latest_recipient
- latest_error (truncated to ~250 runes, ellipsis appended)
- latest_failed_at, latest_failed_error (most recent FAILED row for the slug; independent of the latest_* pointers)
- failed_24h, sent_24h (fixed last-24h window regardless of outer 30d look-back)
- queued (point-in-time count of queued rows)
Error codes:
- 401: no JWT.
- 403: not an admin or missing admin:settings scope.
- 503: email store not configured. Transient during boot.
- 500: DB error reading email_deliveries.
POST test
Path: POST /api/v1/admin/email/test
Performs a synchronous Deliverer.Send against the smtp_test template, addressed to the requested recipient. Returns the inline outcome envelope: success surfaces status = sent; an SMTP-layer failure surfaces status = failed along with the wire-level last_error string. HTTP status is 200 in BOTH cases; the SMTP outcome lives in the body.
Request body: { "recipient_email": "operator@example.com" }
recipient_email is optional. When omitted, the test send goes to the calling admin user email on file (same default as the existing /api/v1/admin/settings/smtp/test endpoint).
Success response (200): { "status": "sent", "recipient_email": "operator@example.com" }
SMTP-failure response (200): { "status": "failed", "last_error": "smtp auth: 535 5.7.8 Error: authentication failed: (reason unavailable)", "recipient_email": "operator@example.com" }
Error codes:
- 400: no recipient and admin has no email on file; OR recipient failed the cheap-shot validator (missing @, no host dot).
- 401: no JWT.
- 403: not an admin or missing admin:settings scope.
- 429: per-admin rate limit (1 req per 10s). Retry-After header set and retry_after_secs in the body.
- 503: email deliverer not configured. Transient during boot.
Audit
Every test-send call writes an audit_log row with:
- action = admin.email.test
- resource = email:smtp_test
- actor_id = admin user id
- details.recipient_email = the recipient
- details.status = sent or failed
- details.last_error = truncated error on failure (only)
Audit emit is detached from the inbound request context so a client disconnect mid-send does not lose the row. Audit failures themselves are non-fatal.