# ADR-0051: M6.5 — Email Features: DB-as-Truth Reconciliation (M6 Pattern Continued)

**Status:** Accepted (2026-04-23)
**Driven by:** Plan `plans/m6.5-email-features.md` (9 steps, 4 waves), research `plans/m6.5-email-features-research.md` (Stalwart schema mapping verified).

## Context

M6 established the **database-as-truth** pattern for mailbox management: the panel-api database holds the canonical mailbox list, a reconciler syncs state to Stalwart via JMAP commands, and manual Stalwart admin-console edits are operator drift (overwritten on next reconciliation cycle).

M6.5 extends this pattern to six new email primitives, all native Stalwart features: **Forwarders** (alias + sieve), **Autoresponders** (JMAP VacationResponse), **Catch-All** (Domain.catchAllAddress), **Disclaimer** (sieve system script), **Shared Folders** (Mailbox.shareWith), and **Logs** (JMAP x:Log + x:Trace queries).

Each feature maps to existing Stalwart schema without new server code. The risk is scope creep: whether to treat Stalwart as truth (operator manages mail rules in Stalwart console, panel is a read-only mirror) or jabali as truth (panel-api database is authoritative, reconciler pushes all state to Stalwart). M6 chose jabali-as-truth for mailboxes; consistency demands the same for M6.5.

## Decision

**Jabali database is the source of truth for all six M6.5 email features.** Stalwart is the system of record (runs the mail server); jabali is the operator's interface and the authoritative copy of desired state. Operator drift (manual Stalwart console edits) is not supported; the reconciler converges state on every tick.

### Rationale

1. **Consistency with M6 mailbox pattern.** M6 set the precedent: mailboxes live in jabali `mailboxes` table, Stalwart holds runtime state. M6.5 follows the same path for forwarders, autoresponders, etc. — same repository pattern, same reconciler loop, same test coverage model.

2. **Operator experience:** One control plane (panel UI + panel-api), not dual-master chaos. If an operator edits Stalwart directly via `stalwart-cli` or the admin console, those changes are lost on the next reconciliation (typically <1min). This is documented in runbooks as the expected behavior.

3. **Cross-domain management.** All M6.5 features are cross-domain tables (like mailboxes). If Stalwart were truth, the panel-api would need to pull state from Stalwart on every fetch + stay in sync with concurrent admin-console edits. That complexity is deferred by electing jabali-as-truth upfront.

4. **Testability and repeatability.** Tests can provision jabali state, run the reconciler, assert Stalwart state. No race against live admin-console changes. Unit and integration tests are deterministic.

5. **Drift detection + audit.** Because jabali is truth, any divergence (Stalwart has fewer/different rules than jabali) is detectable + loggable. Operator can see "drift on domain foo: jabali expects 3 forwarders, Stalwart has 2" + reconciler heals it.

### Scope

- **In scope:** All six features (Forwarders, Autoresponders, Catch-All, Disclaimer, Shared Folders, Logs).
- **Logs is read-only:** The `mail_logs` table is not written by the reconciler. Stalwart x:Log + x:Trace are queryable JMAP resources; the panel fetches them on-demand (no caching). Logs are not "state to converge" — they're observability. No jabali table for logs; Logs tab is a passthrough query.
- **Manual Stalwart edits:** Not supported. If an operator uses `stalwart-cli` to create a forwarder, that state does not appear in jabali; it will be lost (or reverted) on the next reconciliation. Runbook documents this clearly.

### Implementation pattern

Each M6.5 feature follows the same scaffold:

1. **jabali database table** (migrations 000060–000063) holding the operator's desired state.
2. **Go model** (panel-api) representing the jabali row.
3. **Repository** (CRUD interface + SQL implementation) — matches the existing `mailbox_repository.go` shape.
4. **Reconciler phase** (one file per feature under `panel-api/internal/reconciler/phases/`):
   - Fetch desired state from jabali table.
   - Fetch live state from Stalwart via JMAP/CLI.
   - Calculate diff.
   - Push changes to Stalwart (create/update/delete JMAP commands or sieve script rewrites).
   - Update jabali `updated_at` timestamp on success.
5. **API routes** (panel-api/internal/api/routes_m65.go) — CRUD endpoints for the user-shell (GET list, POST create, PATCH update, DELETE delete).
6. **Panel UI hooks** (`panel-ui/src/hooks/use<Feature>.ts`) — TanStack Query wrappers (list, create, update, delete).
7. **Panel UI tabs** (`panel-ui/src/shells/user/mail/tabs/<Feature>Tab.tsx`) — render the CRUD table + dialogs.

### Stalwart integration layer

Per the existing `panel-api/internal/mailbox_jmap.go` pattern, M6.5 introduces feature-specific helpers:

- `panel-api/internal/forwarder_jmap.go` — aliases + sieve-forward wrapper.
- `panel-api/internal/autoresponder_jmap.go` — VacationResponse JMAP bindings.
- `panel-api/internal/catchall_jmap.go` — Domain update wrapper.
- `panel-api/internal/disclaimer_jmap.go` — SieveSystemScript create/update/delete.
- `panel-api/internal/share_jmap.go` — Mailbox.shareWith patch.
- Logs — no new helper; direct `JMAPQuery` in the API handler (passthrough).

Each helper encapsulates Stalwart-specific command syntax. The reconciler calls these, not raw JMAP.

### Operator drift and rollback

**Scenario 1: Operator edits Stalwart directly.**
```
Operator runs: stalwart-cli update x:UserAccount foo set aliases '[{"name": "extra"}]'
Panel UI shows: 3 forwarders (no "extra")
Reconciler next tick: compares jabali (3) vs Stalwart (4 including "extra"), deletes the "extra" alias
Result: state converges back to jabali truth
```

**Scenario 2: Feature disable.**
If a feature step is reverted from main before it ships, its migrations roll back; all jabali tables are dropped. Stalwart retains the actual forwarders/autoresponders/etc., but they're not visible in jabali. Subsequent reconciliation skips that feature (no phase registered). Manual cleanup via `stalwart-cli` is operator responsibility (documented in runbook).

**Scenario 3: Rollback after partial migration.**
If only some steps of M6.5 land before a revert (e.g., Step 1 foundation + Step 2 catch-all, then revert Step 2), the Step 2 migration rolls back cleanly. Stalwart's catch-all remains set; jabali has no table for it. Next reconciler tick skips catch-all (no phase). Operator can re-enable via `stalwart-cli` if needed.

### Limits and open questions

1. **Sieve MIME disclaimer handling:** Research plan (m6.5-email-features-research.md, question 1) flagged that Stalwart sieve `replace` on HTML email parts may not work portably across MIME boundaries. If sieve falls short, M6.5.1 will add an MtaHook fallback (HTTP callback to panel-api for body-rewrite). Jabali-as-truth remains valid; the rewrite method is an implementation detail.

2. **Shared folder principal expansion:** Mailbox.shareWith expects a Stalwart PrincipalId. M6.5 assumes that any mailbox in jabali is shareable. If Stalwart introduces hidden principals or reserved IDs, discovery will be added to the share JMAP helper. Jabali table remains the source.

3. **Log retention in Stalwart:** JMAP x:Log/x:Trace buffer size and retention window are Stalwart-side knobs. M6.5 assumes logs are queryable in real-time; if Stalwart's buffer is small, old logs may be lost. No jabali caching — logs are always current.

## Threat model and security

- **No new attack surface.** Reconciler uses existing JMAP auth (already secured via Unix socket + TLS in M25).
- **Operator visibility:** Jabali-as-truth means the operator sees all state in the panel UI. No hidden Stalwart state.
- **Drift visibility:** If Stalwart state diverges, reconciler logs the mismatch (INFO or WARN level). Operator can see "state out of sync" in logs and trust that the next reconciliation will heal it.

## Reversibility

Each feature is independently reversible:

1. **Revert a single step (e.g., Step 3 Autoresponders):** Its migration rolls back (tables dropped, columns removed). Stalwart retains the live autoresponders; jabali loses the table. Reconciler phase is not registered; autoresponder tab is removed from the UI. Manual cleanup of Stalwart state is operator responsibility.

2. **Revert all of M6.5:** All four migrations roll back in reverse order. Stalwart retains whatever state was set; jabali has no tables. Reconciler has no M6.5 phases. UI has no M6.5 tabs. Clean.

3. **Revert to just foundation (Step 1):** Migrations 000061–000063 roll back; tables are dropped. The reconciler phase registry and API route group stubs remain in code. Re-running Steps 2–7 can re-add migrations without conflict (by number, no overlaps).

## References

- Plan: `plans/m6.5-email-features.md`
- Research: `plans/m6.5-email-features-research.md`
- M6 ADR: `docs/adr/0041-m6-email-stalwart-integration.md`
- Stalwart JMAP RFC 8621: VacationResponse, Mailbox sharing
- Precedent: M6 mailbox pattern (jabali-as-truth, reconciler converges state)
