# ADR-055: Transactional Email Uses mail_outbox Table + External Poller — Not Inline SMTP

## Status

Accepted, 2026-06-22.

## Status History

```yaml
status_history:
  - date: 2026-06-22
    status: Proposed
    changed_by: hkl
    reason: Formalising outbox pattern for transactional email in client invite flow
    changed_via: adr-kit (360lm)
  - date: 2026-06-22
    status: Accepted
    changed_by: hkl
    reason: mail_outbox table + counters-mail-relay poller live; retry up to MAX_ATTEMPTS
    changed_via: adr-kit (360lm)
```

## Context

The Recce client portal send invites and magic-links via email (implemented in the counters/client PWA). Email delivery is not instantaneous and can fail (SMTP timeout, Brevo API rate limit, temporary network issue). Making an SMTP call inline within the invite RPC would: (a) block the RPC response until the email provider responds, (b) fail the entire RPC if the email fails (even though the invite record was created successfully), and (c) provide no retry mechanism for transient failures. The outbox pattern decouples record creation from email delivery.

## Decision

Transactional email (client invites, magic-links) is queued into `mail_outbox` and delivered by a separate `counters-mail-relay` Node container:

1. Invite/magic-link creation RPC inserts the invite record AND a `mail_outbox` row in the same DB transaction. If email delivery fails later, the invite record is still valid.
2. `counters-mail-relay` polls `mail_outbox WHERE status='pending'` every 30 seconds.
3. On each pending row: POST to Brevo `/v3/smtp/email`. On success: mark `status='sent'`. On failure: increment `attempts`; if `attempts >= MAX_ATTEMPTS` (default 3): mark `status='failed'`.
4. **DEV mode** (no `BREVO_API_KEY` set): `console.log` the email content + mark `status='sent'` — enables local testing without a real email account.
5. Inspect delivery: `docker logs counters-mail-relay` shows each send attempt.

**Decision Maker:** hkl

## Alternatives Considered

- **Inline SMTP call within the RPC (synchronous email send).** Rejected: SMTP calls can take 2–5 seconds; blocks the RPC response; if Brevo is down, the invite creation fails even though the DB write succeeded — user must retry the entire invite flow.
- **Client-side email (browser sends via a mailto: link).** Rejected: magic-link tokens must not be exposed to the browser before the recipient opens them; client-side email relies on the user's email client being configured.
- **Serverless email function (trigger on mail_outbox insert).** Rejected: requires a serverless platform (AWS Lambda, etc.) — adds external dependency; the VPS poller achieves the same result with a single Node container already required for other proxy services.
- **Firebase/SendGrid real-time webhook trigger.** Rejected: same external platform dependency concern; Brevo REST API is already the chosen email provider and the poller pattern is simpler than webhooks for this use case.

## Consequences

**Positive:**
- Invite/magic-link creation is instantaneous — RPC returns immediately without waiting for email.
- Transient Brevo failures are automatically retried up to MAX_ATTEMPTS.
- DEV mode allows full local testing of the invite flow without a real Brevo account.
- Delivery audit: every send attempt is visible in container logs and in `mail_outbox.status`.

**Negative / Trade-offs:**
- Email delivery is delayed by up to 30 seconds (poll interval).
- `mail_outbox` rows in `failed` state require manual admin intervention (resend or diagnose).
- Two systems to maintain: DB outbox table + poller container.

**Risks and mitigations:**
- Poller container crashes: `restart: always` ensures it restarts; pending rows remain in `mail_outbox` and are processed on restart.
- MAX_ATTEMPTS exhausted (3 failures): admin receives a notification (if configured) or sees `failed` rows in admin panel; can manually re-queue by resetting status to `pending`.

## Related Decisions

- ADR-048 (external client magic-link) — the invite and magic-link flow that uses this outbox.
- ADR-017 (Traefik routing) — counters-mail-relay does not expose an HTTP endpoint; it is an internal poller, not Traefik-routed.

## References

- `memory/dbt_counters.md` — "Polls mail_outbox WHERE status='pending' every 30s; DEV mode: console.log + mark sent; PROD mode: POST Brevo"
- `mail_outbox` — table: id, to_email, subject, body, status, attempts, created_at
- `docker-compose.yml` — counters-mail-relay service definition
