# M6.5 — Email Features Expansion

**Objective**: Add six new tabs to the user-shell Mail page — **Forwarders, Autoresponders, Catch-All, Disclaimer, Shared Folders, Logs** — each backed by a native Stalwart primitive. The existing **Mailboxes** tab stays the default tab. All six tabs are cross-domain tables (like Mailboxes today); create dialogs pick target domain/mailbox.

**Scope**: 6 new reconciled tables (jabali = cache, Stalwart = truth), ~12 new panel-agent JMAP command wrappers, 6 new user-shell pages + tab routing under `/jabali-panel/mail`, JMAP VacationResponse/SieveScript/Mailbox/Domain/Log/Trace integration via the existing `mailbox_jmap.go` helper pattern.

**Not in scope** (enumerated to head off scope creep):
- Admin-shell mirrors of these tabs. User shell only.
- Bulwark (webmail) UI changes. Panel UI only.
- New Stalwart server configuration beyond what's already installed.
- Shared mailbox creation semantics beyond `Mailbox.shareWith` (no new "shared inbox" object type).
- Migration of existing M6 mailbox plumbing — mailboxes stay exactly where they are.
- Admin-level log aggregation or external log shippers. Logs tab queries Stalwart x:Log/x:Trace directly.
- Catch-all chains / catch-all to external address via forwarder. Catch-all routes to one local mailbox only (Stalwart limitation: `x:Domain.catchAllAddress` is a single emailAddress).

**Branch**: `m6.5/email-features` (integration branch; sub-steps land via `m6.5/email-features/<step-slug>` PRs; umbrella merges to `main` only after Step 9 green on test VM 192.168.100.150).

**Anchors** (verified 2026-04-23 against `origin/main` @ `f04d4f2`):

- **Panel UI Mail page**: `panel-ui/src/shells/user/mailboxes/UserMailboxesPage.tsx` (391 lines, single `<Card>` + `<Table>` — flat cross-domain list). Route mounted at `panel-ui/src/App.tsx:183` via `<Route path="mailboxes" element={<UserMailboxesPage />} />`. M6.5 converts this into a `<Tabs>` shell with 7 panels (existing Mailboxes becomes default tab).
- **Nav link**: `panel-ui/src/nav.ts:174-177` — label "Email", path `/jabali-panel/mailboxes`, icon `MailOutlined`. Label becomes **"Mail"** in Step 1.
- **JMAP client**: `panel-agent/internal/commands/mailbox_jmap.go` (619 lines) is the shared helper for every stalwart JMAP call. New commands reuse `jmapCall()` at L139. Auth via Stalwart admin token, endpoint `/jmap` over the locked-down loopback socket (M25).
- **Existing M6 agent commands** for pattern reference: `mailbox_create.go`, `mailbox_delete.go`, `mailbox_set_password.go`, `mailbox_set_quota.go`, `mailbox_usage.go`, `domain_email_enable.go`, `domain_email_disable.go`. Each wraps a single JMAP method call + DB-side state check.
- **Domain model**: `panel-api/internal/models/domain.go` — `EmailEnabled`, `DkimSelector`, `DkimPublicKey`, `EmailEnabledAt` already exist from M6. Catch-all + disclaimer land as new columns in `domains` (migration 000063, Step 1).
- **Mailbox model**: `panel-api/internal/models/mailbox.go` — add foreign-key columns for forwarder/autoresponder/sharing in new child tables, not on `mailboxes` itself.
- **Reconciler**: `panel-api/internal/reconciler/reconciler.go` `ReconcileAll` loop runs every 60s; email path already handles DKIM/MX/TXT. M6.5 adds one new convergence phase per feature (forwarders, autoresponders, catch-all, disclaimer, shares) — each idempotent + guarded by `managed_by` shape from M6.
- **Data provider**: `panel-ui/src/dataProvider.ts` — existing envelope `{data, total, page, page_size}`. All new resources use same shape. Ref: `feedback_verify_wire_contract.md`.
- **Latest migration on main**: `000059_domains_is_panel_primary` (verified 2026-04-23). Next 4 numbers: **000060** (create `email_forwarders`), **000061** (create `email_autoresponders`), **000062** (ALTER `domains` +catchall +disclaimer), **000063** (create `mailbox_shares`). Logs are pass-through — no table needed. Audit rule per `feedback_merge_audit_migrations.md`: every ALTER references a table that already exists on main; every new CREATE stands alone. Verify with `ls panel-api/internal/db/migrations | tail -1` before writing Step 1.
- **Stalwart research**: `plans/m6.5-email-features-research.md` (written 2026-04-23) maps each UI tab to its Stalwart primitive. Read this first before any step.

**Version pins** (none new; all on main):
- Stalwart v0.16.0 (M6, ADR-0045)
- Bulwark 1.4.14 (M6.2)
- pdns-recursor 5.2.8 (M6.3)

**Target ADRs**:
- **ADR-0051** (draft in Step 1, accept in Step 9): "M6.5 Email Features — DB-as-Truth Reconciliation, M6 Pattern Continued". ADR-0002 (DB-as-truth) applies unchanged: authoritative intent lives in jabali MariaDB tables; reconciler converges jabali → Stalwart; operator edits via Stalwart admin UI are drift and get overwritten on next tick. This ADR extends the M6 pattern (DKIM/MX/Stalwart domain/mailbox) to 6 new feature surfaces — it documents the extension, not a new architectural pattern.
- **ADR-0052** (spike in Step 6, finalize in Step 9): "Disclaimer: Sieve Script vs MtaHook". Decision deferred pending two live spikes on 192.168.100.150: (1) can sieve `replace`+`foreverypart` touch HTML body parts portably? (2) if fallback needed, does `x:MtaHook` accept `unix://` URLs (M25 closed loopback TCP — a TCP-only MtaHook would re-open it)? If neither spike succeeds, disclaimer feature defers to M6.6 (needs a local MtaHook broker). Defaults to sieve; fallback only if both spikes succeed.

**Dependencies** (all shipped on main):
- M6 Email (Stalwart + per-domain mail vhost + mailbox JMAP) — ADRs 0041-0045
- M6.1 Cert SAN expansion for `mail.<domain>`
- M6.2 Webmail SSO bridge (works unchanged on new tabs)
- M6.3 pdns-recursor self-zone (mail records routable on `.local`)
- M6.4 Panel hostname primary mail (ADR-0048 + 7e8c5f6 `.local` self-heal)
- M25 Unix sockets — Stalwart admin now at `127.0.0.1:8080`; all JMAP goes loopback

---

## Waves & Parallelism

| Wave | Steps | Mode | Gate |
|------|-------|------|------|
| **A** Foundation | 1 | serial | Must complete before Wave B |
| **B** Simple features | 2, 3, 4 | **parallel** | All three green → Wave C |
| **C** Complex features | 5, 6, 7 | **parallel** | All three green → Wave D |
| **D** Ship | 8, 9 | serial | Live smoke on 192.168.100.150 before memory update |

Parallel waves B and C are safe because each step touches a disjoint set of DB tables, agent command files, and UI pages. Shared files (App.tsx routing, nav.ts, dataProvider) are touched only in Step 1 and Step 9.

---

## Step 1 — Foundation: ADR, migrations, tab shell, hook scaffolding

**Model tier**: strongest (locks data model + cross-cutting patterns for all later steps).
**Branch**: `m6.5/email-features/01-foundation`.
**Blocks**: Steps 2, 3, 4, 5, 6, 7.

### Context brief

Establishes the load-bearing foundations every later step depends on. **This step exists specifically to enable real parallelism in Waves B and C by pre-creating per-feature files** — without it, every parallel step would collide on `router.go`, `reconciler.go`, `App.tsx`, and `nav.ts`.

1. The ADR that locks DB-as-truth (extension of M6 pattern).
2. All 4 reconciled migrations (one per feature; Logs is pass-through). Shipping together prevents numbering races.
3. **Phase registry pattern for reconciler** — main `reconciler.go` gets a registered-phases slice; each feature in Waves B/C adds its own file under `panel-api/internal/reconciler/phases/` (one file per feature) registered via the registry. Main `reconciler.go` is touched ONLY in Step 1 to add the registry; Wave B/C steps never edit it again.
4. **Route group registry for API** — `panel-api/internal/api/routes_m65.go` holds 6 empty route-group stubs (`registerCatchallRoutes`, `registerAutorespondersRoutes`, etc.). Main `router.go` calls `RegisterM65Routes(r)` once in Step 1; Wave B/C steps only edit their own stub. No more contention on `router.go`.
5. Tabs shell that replaces `UserMailboxesPage.tsx`'s single `<Card>` wrapper — existing Mailboxes table becomes default tab content; five new tabs render `<Skeleton>` placeholders until their step lands.
6. Skeleton hook files (`useForwarders.ts`, `useAutoresponders.ts`, `useCatchAll.ts`, `useDisclaimer.ts`, `useSharedFolders.ts`, `useMailLogs.ts`) — each exports a typed `useListQuery`/`useCreate`/`useUpdate`/`useDelete` per the existing `useMailboxes.ts` pattern. Stubs return empty arrays + no-op mutations until backend lands.

Files touched in Step 1 only (NEVER again in Wave B/C):
- `panel-api/internal/api/router.go` — add `RegisterM65Routes(r)` call.
- `panel-api/internal/reconciler/reconciler.go` — add phase registry slice + loop.
- `panel-ui/src/App.tsx` — add `<Route path="mail">` with child routes for each tab.
- `panel-ui/src/nav.ts` — rename Email → Mail.
- `panel-ui/src/dataProvider.ts` (if any resource-name registry exists) — pre-register all 6 new resource names.

### Files to write

**ADR**:
- `docs/adr/0051-m6.5-email-features-stalwart-as-truth.md` (new)

**Migrations** (verify next number with `ls panel-api/internal/db/migrations | tail -1` before writing; expected latest = `000059`):
- `000060_create_email_forwarders.up.sql` / `.down.sql`
- `000061_create_email_autoresponders.up.sql` / `.down.sql`
- `000062_domains_add_catchall_and_disclaimer.up.sql` / `.down.sql`
- `000063_create_mailbox_shares.up.sql` / `.down.sql`

(No migration for Logs — x:Log/x:Trace pass-through; see ADR-0051.)

Schema sketches (tighten syntax in implementation):

```sql
-- 000060 email_forwarders
CREATE TABLE email_forwarders (
  id CHAR(26) PRIMARY KEY,
  mailbox_id CHAR(26) NOT NULL,          -- target mailbox (recipient of the forwarded mail)
  domain_id CHAR(26) NOT NULL,           -- domain of the source address
  type ENUM('alias','external') NOT NULL,
  local_part VARCHAR(64) NULL,           -- NOT NULL for type='alias'; source local-part (e.g. "sales"); NULL for 'external'
  target VARCHAR(255) NOT NULL,          -- for 'external': the outside email; for 'alias': redundantly holds the mailbox@domain for display
  enabled BOOLEAN NOT NULL DEFAULT TRUE,
  managed_by VARCHAR(16) DEFAULT 'm6.5',
  created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
  updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  CONSTRAINT fk_fwd_mailbox FOREIGN KEY (mailbox_id) REFERENCES mailboxes(id) ON DELETE CASCADE,
  CONSTRAINT fk_fwd_domain FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
  -- Alias local-part must be unique per domain across ALL mailboxes (Stalwart rejects duplicates).
  -- Generated column so MariaDB can enforce uniqueness only for aliases.
  alias_key VARCHAR(320) AS (CASE WHEN type='alias' THEN CONCAT(domain_id, '::', local_part) ELSE NULL END) STORED,
  UNIQUE KEY uq_alias_key (alias_key),
  -- For external forwards: prevent duplicate (mailbox_id, target) pairs.
  UNIQUE KEY uq_external (mailbox_id, type, target)
);

-- 000062 email_autoresponders (one per mailbox)
CREATE TABLE email_autoresponders (
  mailbox_id CHAR(26) PRIMARY KEY,
  enabled BOOLEAN NOT NULL DEFAULT FALSE,
  from_date TIMESTAMP(6) NULL,
  to_date TIMESTAMP(6) NULL,
  subject VARCHAR(998) NULL,
  text_body TEXT NULL,
  html_body MEDIUMTEXT NULL,
  managed_by VARCHAR(16) DEFAULT 'm6.5',
  updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  CONSTRAINT fk_ar_mailbox FOREIGN KEY (mailbox_id) REFERENCES mailboxes(id) ON DELETE CASCADE
);

-- 000063 domains add catchall_target + disclaimer_enabled + disclaimer_text
ALTER TABLE domains
  ADD COLUMN catchall_target VARCHAR(255) NULL,
  ADD COLUMN disclaimer_enabled BOOLEAN NOT NULL DEFAULT FALSE,
  ADD COLUMN disclaimer_text TEXT NULL;

-- 000064 mailbox_shares
CREATE TABLE mailbox_shares (
  id CHAR(26) PRIMARY KEY,
  owner_mailbox_id CHAR(26) NOT NULL,
  shared_with_mailbox_id CHAR(26) NOT NULL,
  rights JSON NOT NULL,
  managed_by VARCHAR(16) DEFAULT 'm6.5',
  created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
  CONSTRAINT fk_share_owner FOREIGN KEY (owner_mailbox_id) REFERENCES mailboxes(id) ON DELETE CASCADE,
  CONSTRAINT fk_share_other FOREIGN KEY (shared_with_mailbox_id) REFERENCES mailboxes(id) ON DELETE CASCADE,
  UNIQUE KEY uq_share (owner_mailbox_id, shared_with_mailbox_id)
);
```

**Models**:
- `panel-api/internal/models/email_forwarder.go` (new)
- `panel-api/internal/models/email_autoresponder.go` (new)
- `panel-api/internal/models/mailbox_share.go` (new)
- `panel-api/internal/models/domain.go` (extend — add `CatchallTarget`, `DisclaimerEnabled`, `DisclaimerText`)

**Parallelism scaffolding** (critical — enables Wave B/C parallel dispatch without collisions):
- `panel-api/internal/reconciler/phases/registry.go` (new — defines `Phase` interface + `Register(p Phase)` + `All() []Phase`)
- `panel-api/internal/reconciler/phases/doc.go` (new — explains the pattern: one file per feature, `init()` registers)
- `panel-api/internal/api/routes_m65.go` (new — `RegisterM65Routes(r *gin.Engine)` calls 6 empty sub-registrars: `registerCatchallRoutes`, `registerAutorespondersRoutes`, `registerSharedFoldersRoutes`, `registerForwardersRoutes`, `registerDisclaimerRoutes`, `registerMailLogsRoutes` — each defined as empty stubs in this same file, to be filled inline by each feature step EDITING ONLY ITS OWN FUNCTION)
- `panel-api/internal/reconciler/reconciler.go` (extend — add `for _, p := range phases.All() { p.Run(ctx, r) }` loop once; never touched again in Wave B/C)
- `panel-api/internal/api/router.go` (extend — call `RegisterM65Routes(r)` once; never touched again in Wave B/C)

**Repositories** (thin — full CRUD written per feature in its step):
- `panel-api/internal/repository/email_forwarder_repository.go` (stub: interface only)
- `panel-api/internal/repository/email_autoresponder_repository.go` (stub)
- `panel-api/internal/repository/mailbox_share_repository.go` (stub)

**Panel UI**:
- Rename `panel-ui/src/shells/user/mailboxes/UserMailboxesPage.tsx` → `panel-ui/src/shells/user/mail/MailTabsPage.tsx` (keeps existing code as the Mailboxes tab content extracted to `panel-ui/src/shells/user/mail/tabs/MailboxesTab.tsx`)
- Six new placeholder tab files under `panel-ui/src/shells/user/mail/tabs/`:
  - `ForwardersTab.tsx` → `<Skeleton>` + text "Coming in Step 5"
  - `AutorespondersTab.tsx` → `<Skeleton>` + "Coming in Step 3"
  - `CatchAllTab.tsx` → `<Skeleton>` + "Coming in Step 2"
  - `DisclaimerTab.tsx` → `<Skeleton>` + "Coming in Step 6"
  - `SharedFoldersTab.tsx` → `<Skeleton>` + "Coming in Step 4"
  - `LogsTab.tsx` → `<Skeleton>` + "Coming in Step 7"
- `panel-ui/src/App.tsx` L66/L183 — update import and route (`path="mail"` with child index routes per tab; keep `/jabali-panel/mailboxes` as a `<Navigate>` to `/jabali-panel/mail` for URL backwards-compat)
- `panel-ui/src/nav.ts:174-177` — label "Email" → "Mail", path stays `/jabali-panel/mailboxes` initially and is redirected; DO NOT break bookmarks.

**Hook scaffolding** (thin — data-layer writes happen in each feature's step):
- `panel-ui/src/hooks/useForwarders.ts` (stub returning empty list)
- `panel-ui/src/hooks/useAutoresponders.ts` (stub)
- `panel-ui/src/hooks/useCatchAll.ts` (stub)
- `panel-ui/src/hooks/useDisclaimer.ts` (stub)
- `panel-ui/src/hooks/useSharedFolders.ts` (stub)
- `panel-ui/src/hooks/useMailLogs.ts` (stub)

### Tasks (check off in order)

- [ ] Verify next migration number: `ls panel-api/internal/db/migrations | tail -1` — if not 000060, bump plan numbers accordingly.
- [ ] Write ADR-0051. Cover: Stalwart-as-truth; jabali cache + reconciler converges; manual Stalwart admin console edits = operator drift, overwritten on next tick; all six features.
- [ ] Write migrations 000061-000066 (up + down). Verify each `down.sql` fully reverts via `make migrate-down`.
- [ ] Write model structs + extend `domain.go`.
- [ ] Add stub repository interfaces (one method per: `Create`, `List`, `Get`, `Update`, `Delete`). Later steps implement concrete types.
- [ ] Rename mailboxes page → `MailTabsPage.tsx`. Extract table into `MailboxesTab.tsx`. Wire AntD `<Tabs>` with 7 items.
- [ ] Update `App.tsx` route. Add redirect from `/jabali-panel/mailboxes` → `/jabali-panel/mail/mailboxes`.
- [ ] Update `nav.ts` label "Email" → "Mail".
- [ ] Write 6 stub hooks with same shape as `useMailboxes.ts` (TanStack Query keys, list/create/update/delete).
- [ ] Run `go test ./...`. Must pass.
- [ ] Run `npm --prefix panel-ui run build`. Must succeed.
- [ ] Run `make migrate-up && make migrate-down && make migrate-up` on a dev DB. Must cycle cleanly.

### Verification

```bash
go build ./...
go test ./panel-api/internal/models/...
make -C panel-api migrate-up migrate-down migrate-up
npm --prefix panel-ui run build
# Manual browser verify: navigate /jabali-panel/mail, see 7 tabs, Mailboxes default tab works identically to before
```

### Exit criteria

- All 6 migrations apply + revert cleanly.
- Browser shows 7 tabs on `/jabali-panel/mail`. Mailboxes tab functions identically to pre-M6.5.
- `/jabali-panel/mailboxes` redirects to `/jabali-panel/mail/mailboxes`.
- TS build + Go build + tests green.
- ADR-0051 committed.
- Branch rebased onto `origin/main`; report branch name, SHA, `git log main..HEAD` summary.

### Rollback

Revert PR. Migrations are self-contained (000061-000066); their down.sql removes tables + columns cleanly. `/jabali-panel/mailboxes` URL still exists (the redirect is additive).

---

## Step 2 — Catch-All

**Model tier**: default. **Branch**: `m6.5/email-features/02-catchall`. **Depends on**: Step 1. **Parallel with**: Steps 3, 4.

### Context brief

Catch-all is the simplest feature. Per-domain, single-value, maps to native `x:Domain.catchAllAddress` on Stalwart. DB source is the `domains.catchall_target` column added in Step 1.

**Stalwart surface**:
```
x:Domain { id, ..., catchAllAddress: emailAddress | null }
```
Set via agent `domain.catchall_set` → `stalwart-cli` update on `x:Domain`.

### Files to touch

- `panel-agent/internal/commands/domain_catchall_set.go` (new — wraps JMAP `x:Domain/set` with `catchAllAddress` patch)
- `panel-agent/internal/commands/domain_catchall_clear.go` (new — patch to null)
- `panel-agent/internal/commands/domain_catchall_test.go` (new — testdata-driven, golden-fixture)
- `panel-api/internal/api/domain_catchall.go` (new — `GET /domains/:id/catchall`, `PUT /domains/:id/catchall`, `DELETE /domains/:id/catchall`)
- `panel-api/internal/api/domain_catchall_test.go` (new)
- `panel-api/internal/api/routes_m65.go` (edit ONLY the `registerCatchallRoutes` function body — do NOT touch other stubs)
- `panel-api/internal/reconciler/phases/catchall.go` (new — implements `Phase` interface; `init()` registers self. Converges jabali `catchall_target` → Stalwart `x:Domain.catchAllAddress`)
- `panel-api/internal/reconciler/phases/catchall_test.go` (new)
- `panel-ui/src/hooks/useCatchAll.ts` (replace stub with real hook)
- `panel-ui/src/shells/user/mail/tabs/CatchAllTab.tsx` (replace stub)
- `panel-ui/src/shells/user/mail/tabs/CatchAllTab.test.tsx` (new)

### UI sketch

Cross-domain table: columns = Domain, Catch-All Target, Status (enabled/disabled), Actions. "Add catch-all" dialog: domain picker (email-enabled domains only) + target email input + enable toggle. Edit in-place via row click.

### Verification

```bash
go test ./panel-agent/internal/commands/... -run TestDomainCatchall
go test ./panel-api/internal/reconciler/... -run TestReconcile_Catchall
go test ./panel-api/internal/api/... -run TestCatchall
npm --prefix panel-ui run test -- CatchAllTab
# Live: set catch-all via UI, send mail to nonexistent@<domain>, verify arrives at target inbox
```

### Exit criteria

- Create/edit/delete catch-all via UI round-trips to Stalwart.
- Reconciler self-heals drift (operator changes via Stalwart admin → reverted on next tick).
- Disable catch-all → Stalwart `x:Domain.catchAllAddress` becomes null.
- Rebased on `origin/main`, tests green post-rebase.

---

## Step 3 — Autoresponders

**Model tier**: default. **Branch**: `m6.5/email-features/03-autoresponders`. **Depends on**: Step 1. **Parallel with**: Steps 2, 4.

### Context brief

Per-mailbox vacation reply, maps to native JMAP `VacationResponse` singleton (RFC 8621 §8). Source: `email_autoresponders` table (Step 1).

**Stalwart surface**:
```
VacationResponse (singleton per account) {
  isEnabled, fromDate, toDate, subject, textBody, htmlBody
}
```
JMAP method: `VacationResponse/get` + `VacationResponse/set`. Agent acts as the mailbox's account via admin impersonation (pattern already in `mailbox_jmap.go` — see `authMailboxAs()`).

### Files to touch

- `panel-agent/internal/commands/autoresponder_get.go` (new)
- `panel-agent/internal/commands/autoresponder_set.go` (new)
- `panel-agent/internal/commands/autoresponder_test.go` (new)
- `panel-api/internal/api/mailbox_autoresponder.go` (new — `GET/PUT/DELETE /mailboxes/:id/autoresponder`)
- `panel-api/internal/api/mailbox_autoresponder_test.go` (new)
- `panel-api/internal/api/routes_m65.go` (edit ONLY `registerAutorespondersRoutes` function body)
- `panel-api/internal/reconciler/phases/autoresponder.go` (new — registered phase)
- `panel-api/internal/reconciler/phases/autoresponder_test.go` (new)
- `panel-ui/src/hooks/useAutoresponders.ts` (real hook)
- `panel-ui/src/shells/user/mail/tabs/AutorespondersTab.tsx` (real page)
- `panel-ui/src/shells/user/mail/tabs/AutorespondersTab.test.tsx` (new)

### UI sketch

Cross-domain table: columns = Mailbox, Status, Date range, Subject (truncated), Actions (enable/disable/edit). "Set autoresponder" dialog: mailbox picker, date range, subject, plain-text body, HTML body (AntD TextArea + switch for HTML editor).

### Verification

```bash
go test ./panel-agent/internal/commands/... -run TestAutoresponder
go test ./panel-api/internal/reconciler/... -run TestReconcile_Autoresponder
npm --prefix panel-ui run test -- AutorespondersTab
# Live: set autoresponder on a test mailbox, send mail from external, verify reply arrives with correct subject/body
```

### Exit criteria

- Autoresponder round-trips UI → jabali DB → Stalwart JMAP `VacationResponse`.
- Date-range bounds honored by Stalwart (set fromDate in future → no reply yet).
- Disabling clears Stalwart via VacationResponse/set with isEnabled=false.
- Rebased, tests green.

---

## Step 4 — Shared Folders

**Model tier**: default. **Branch**: `m6.5/email-features/04-shared-folders`. **Depends on**: Step 1. **Parallel with**: Steps 2, 3.

### Context brief

Per-mailbox sharing via native JMAP `Mailbox.shareWith` (map `PrincipalId → MailboxRights`). Source: `mailbox_shares` table. Jabali stores the authoritative intent (owner, target, rights); reconciler applies via `Mailbox/set` patch.

**Stalwart surface**:
```
Mailbox.shareWith: Map<PrincipalId, MailboxRights>
MailboxRights: { mayRead, mayAddItems, mayRemoveItems, mayCreateChild, mayRename, mayDelete, mayAdmin, maySubmit, ... }
```

### Files to touch

- `panel-agent/internal/commands/mailbox_share.go` (new)
- `panel-agent/internal/commands/mailbox_unshare.go` (new)
- `panel-agent/internal/commands/mailbox_share_test.go` (new)
- `panel-api/internal/api/mailbox_share.go` (new — `GET/POST/DELETE /mailboxes/:id/shares`)
- `panel-api/internal/api/mailbox_share_test.go` (new)
- `panel-api/internal/api/routes_m65.go` (edit ONLY `registerSharedFoldersRoutes` function body)
- `panel-api/internal/reconciler/phases/mailbox_share.go` (new — registered phase)
- `panel-api/internal/reconciler/phases/mailbox_share_test.go` (new)
- `panel-ui/src/hooks/useSharedFolders.ts` (real hook)
- `panel-ui/src/shells/user/mail/tabs/SharedFoldersTab.tsx` (real page)
- `panel-ui/src/shells/user/mail/tabs/SharedFoldersTab.test.tsx` (new)

### UI sketch

Cross-domain table: columns = Owner mailbox, Shared with, Rights (comma-joined), Folder (root or subfolder path), Actions. "Share folder" dialog: source mailbox picker + target mailbox picker + rights checkbox group + optional sub-folder path (default: share all folders).

### Verification

```bash
go test ./panel-agent/internal/commands/... -run TestMailboxShare
go test ./panel-api/internal/reconciler/... -run TestReconcile_MailboxShare
npm --prefix panel-ui run test -- SharedFoldersTab
# Live: share a folder from mailbox A to mailbox B, log into B's webmail, verify shared folder appears with correct rights
```

### Exit criteria

- Shares round-trip UI → DB → Stalwart.
- Target mailbox sees shared folder in webmail (Bulwark) with correct rights (read / read-write).
- Unshare removes the entry from Stalwart's `shareWith`.
- Rebased, tests green.

---

## Step 5 — Forwarders (two-flavor)

**Model tier**: strongest (most nuanced — two Stalwart surfaces under one UI). **Branch**: `m6.5/email-features/05-forwarders`. **Depends on**: Step 1. **Parallel with**: Steps 6, 7.

### Context brief

**Forwarders** unifies two Stalwart concepts under one UI:

1. **Alias** (Type=`alias`): Mail to `sales@domain` also delivers to an existing mailbox on the same domain. Backed by `x:UserAccount.aliases` (objectList of `x:EmailAlias{name, domainId}`).
2. **External forward** (Type=`external`): Mail to mailbox@domain redirects to an outside address. Backed by `x:SieveUserScript` with a `redirect :copy` action.

Both surface as rows in one table with a `Type` column. Create dialog asks Type first, then shows the appropriate fields.

### Files to touch

- `panel-agent/internal/commands/forwarder_alias_set.go` (new — patch `x:UserAccount.aliases`)
- `panel-agent/internal/commands/forwarder_alias_delete.go` (new)
- `panel-agent/internal/commands/forwarder_sieve_set.go` (new — upsert `x:SieveUserScript` named `jabali-fwd-<forwarder-id>` with `redirect :copy "<target>";` body)
- `panel-agent/internal/commands/forwarder_sieve_delete.go` (new)
- `panel-agent/internal/commands/forwarder_test.go` (new — both flavors)
- `panel-api/internal/api/mailbox_forwarder.go` (new)
- `panel-api/internal/api/mailbox_forwarder_test.go` (new)
- `panel-api/internal/api/routes_m65.go` (edit ONLY `registerForwardersRoutes` function body)
- `panel-api/internal/reconciler/phases/forwarders.go` (new — registered phase with type-switch)
- `panel-api/internal/reconciler/phases/forwarders_test.go` (new)
- `panel-ui/src/hooks/useForwarders.ts` (real hook)
- `panel-ui/src/shells/user/mail/tabs/ForwardersTab.tsx` (real page)
- `panel-ui/src/shells/user/mail/tabs/ForwardersTab.test.tsx` (new)

### UI sketch

Cross-domain table: columns = Source (mailbox@domain or alias@domain), Type (Alias/External), Target, Status, Actions. "Add forwarder" dialog (stepped wizard):
1. Pick source mailbox
2. Pick Type (Alias vs External)
3. If Alias → enter local-part (domain inherited from mailbox)
4. If External → enter target email

Validation: prevent alias collisions with existing mailboxes; prevent external forwards to addresses on same Stalwart server (would loop).

### Sieve template (Type=external)

```sieve
require ["copy", "envelope"];
# jabali-managed; do not edit — overwritten by reconciler
redirect :copy "${TARGET}";
```

**Sieve script naming + concat (decided, not deferred)**: Stalwart `SieveScript.isActive` schema says "Only one script may be active per account." So multi-forwarder mailboxes **must** concatenate into a single sieve script per mailbox. Name: `jabali-fwds-<mailbox-id>` (one active script per mailbox). Body is rebuilt idempotently from all rows where `mailbox_id=X AND type='external' AND enabled=1` — one `redirect :copy "<target>";` line per row. The reconciler regenerates + uploads this script on every convergence tick.

DB schema is unaffected (row-per-forwarder in `email_forwarders`); only the agent command that composes the sieve body changes — it reads all of a mailbox's external forwarders and emits one concatenated script.

### Verification

```bash
go test ./panel-agent/internal/commands/... -run TestForwarder
go test ./panel-api/internal/reconciler/... -run TestReconcile_Forwarder
npm --prefix panel-ui run test -- ForwardersTab
# Live alias: create alias sales@domain → mailbox X; send to sales@domain; verify arrives at X
# Live external: create forward mailboxY@domain → ext@gmail.com; send to mailboxY; verify arrives at gmail
```

### Exit criteria

- Both alias and external forwarders round-trip.
- Disable forwarder → Stalwart alias/sieve removed on next tick.
- Delete mailbox → forwarders cascade via FK.
- Rebased, tests green.

---

## Step 6 — Disclaimer (sieve with MtaHook fallback)

**Model tier**: strongest (spike risk — sieve-MIME body rewrite may not work on HTML parts). **Branch**: `m6.5/email-features/06-disclaimer`. **Depends on**: Step 1. **Parallel with**: Steps 5, 7.

### Context brief

Per-domain outbound disclaimer. Default approach: system sieve script using `body`, `replace`, `foreverypart` extensions. Script matches outbound messages from the domain and appends disclaimer text to body parts.

**Spike protocol (first 3h of this step, BEFORE writing any agent code — record results in ADR-0052)**:

**Spike A — can sieve body/replace/foreverypart touch HTML parts?**
1. Draft sieve script manually; install via stalwart-cli on 192.168.100.150.
2. Send test email from a mailbox with text/plain body → verify disclaimer appended.
3. Send test email with `multipart/alternative` (text/plain + text/html) → verify disclaimer appended to BOTH parts.
4. Send test email with HTML-only body → verify disclaimer appended.

**If A succeeds** → use sieve path. Skip spike B.

**If A fails on 3 or 4** → run Spike B BEFORE committing to MtaHook.

**Spike B — does `x:MtaHook` accept unix:// URLs? (M25 constraint)**

Memory `project_m25_1_shipped.md`: TCP :3306 closed, panel-api on unix socket. If MtaHook only accepts `http://host:port/` URLs, the fallback would re-open a loopback TCP port — architectural conflict with M25. Before committing:
1. Read Stalwart schema: `jq '.fields["x:MtaHook"].properties' /tmp/schema.json` → check URL field type + docs.
2. If `unix://` supported: MtaHook over unix socket is viable, fallback path proceeds.
3. If TCP-only: DISCLAIMER FEATURE DEFERS TO M6.6. Reason: the only remaining option is hosting a local MtaHook broker (e.g. a tiny Rust/Go daemon speaking Milter, bridging unix→MtaHook) — that's a separate project, not an M6.5 sub-step.

**Decision matrix recorded in ADR-0052**:
| Spike A | Spike B | Action |
|---------|---------|--------|
| passes | — | Sieve path, ship in M6.5 |
| fails | unix:// supported | MtaHook path (panel-api hosts endpoint on its unix socket), ship in M6.5 |
| fails | TCP-only | Defer disclaimer tab to M6.6; tab stays as "Coming soon" skeleton; UI placeholder explains outcome |

**Do NOT open loopback TCP under any circumstances. M25 is load-bearing.**

### Files to touch (sieve path)

- `panel-agent/internal/commands/domain_disclaimer_set.go` (new — upsert `x:SieveSystemScript` named `jabali-disclaimer-<domain-id>`)
- `panel-agent/internal/commands/domain_disclaimer_clear.go` (new)
- `panel-agent/internal/commands/domain_disclaimer_test.go` (new)
- `panel-agent/internal/commands/sieve_templates/disclaimer.sieve.tmpl` (new — templated per-domain)

### Files to touch (MtaHook fallback — only if Spike A fails AND Spike B confirms unix:// support)

- `panel-api/internal/api/mta_hook_disclaimer.go` (new — unix-socket HTTP handler on existing panel-api UDS; NO new listener)
- `panel-api/internal/mimeutil/disclaimer.go` (new — MIME parse + append)
- `panel-agent/internal/commands/domain_disclaimer_set.go` (instead registers `x:MtaHook` with stage=data, url=`unix:///run/jabali-panel/api.sock/internal/mta-hook/disclaimer` — exact URL form depends on Spike B schema read)

**If Spike B shows TCP-only MtaHook**: abandon this file list; defer disclaimer to M6.6 per ADR-0052 decision matrix. Do NOT open a loopback TCP port.

### Files shared (either path)

- `panel-api/internal/api/domain_disclaimer.go` (new)
- `panel-api/internal/api/domain_disclaimer_test.go` (new)
- `panel-api/internal/api/routes_m65.go` (edit ONLY `registerDisclaimerRoutes` function body)
- `panel-api/internal/reconciler/phases/disclaimer.go` (new — registered phase)
- `panel-api/internal/reconciler/phases/disclaimer_test.go` (new)
- `panel-ui/src/hooks/useDisclaimer.ts` (real hook)
- `panel-ui/src/shells/user/mail/tabs/DisclaimerTab.tsx` (real page — matches mockup: title + toggle + TextArea + Save/Cancel)
- `panel-ui/src/shells/user/mail/tabs/DisclaimerTab.test.tsx` (new)

### UI sketch (matches the user-provided mockup)

Cross-domain table: columns = Domain, Enabled, Preview (first 60 chars), Updated, Actions. Row click → drawer/modal with Disclaimer mockup layout: Domain header, Enable toggle, Disclaimer Text TextArea, Save/Cancel.

### Sieve template (first attempt)

```sieve
require ["envelope", "body", "replace", "foreverypart"];
# jabali-managed disclaimer for domain ${DOMAIN_NAME}; do not edit — overwritten by reconciler
if envelope :domain "from" "${DOMAIN_NAME}" {
  foreverypart {
    if header :mime :contenttype :is "content-type" "text/plain" {
      replace text:
${ORIGINAL_BODY}

-- 
${DISCLAIMER_TEXT}
.
;
    }
  }
}
```

### Verification

```bash
go test ./panel-agent/internal/commands/... -run TestDisclaimer
go test ./panel-api/internal/reconciler/... -run TestReconcile_Disclaimer
npm --prefix panel-ui run test -- DisclaimerTab
# Live spike (BEFORE committing): stalwart-cli upload disclaimer.sieve manually; send text/plain + multipart; grep msg for disclaimer
# Live end-to-end: enable via UI, send from test mailbox, verify disclaimer appears in received message (both Gmail + local)
```

### Exit criteria

- Disclaimer toggles on/off cleanly via UI.
- Live test shows disclaimer in received text AND html parts.
- If MtaHook path chosen: hook endpoint survives Stalwart restart (no stuck state).
- ADR-0052 finalized with chosen approach + why.
- Rebased, tests green.

### Rollback

Disable disclaimer on all domains (DB update → reconciler removes sieve scripts). If MtaHook registered, unregister via Stalwart admin.

---

## Step 7 — Logs

**Model tier**: default. **Branch**: `m6.5/email-features/07-logs`. **Depends on**: Step 1. **Parallel with**: Steps 5, 6.

### Context brief

Read-only mail log viewer. No jabali table — pass-through JMAP query to Stalwart `x:Log` + `x:Trace`. Results filtered to only include log entries for domains owned by the authenticated user.

**Stalwart surfaces**:
```
x:Log   { timestamp, event (enum EventType), level (enum TracingLevel), details }
x:Trace { timestamp, from, to, size, events: [TraceEvent] }
```

### Files to touch

- `panel-agent/internal/commands/logs_query.go` (new — JMAP `x:Log/query` + `x:Trace/query` with filter + pagination)
- `panel-agent/internal/commands/logs_query_test.go` (new)
- `panel-api/internal/api/mail_logs.go` (new — `GET /mail/logs?from_date=&to_date=&sender=&recipient=&page=&page_size=`; user-scoping enforced server-side — query whitelist = the user's own mailboxes + domains. Pagination mode locked pre-dispatch: verify `x:Log/query` JMAP method uses `position/limit` (offset) vs `anchor/anchorOffset` (cursor) via schema inspection; record in step notes before coding.)
- `panel-api/internal/api/mail_logs_test.go` (new — scoping test MUST fail if a user can fetch another user's logs)
- `panel-api/internal/api/routes_m65.go` (edit ONLY `registerMailLogsRoutes` function body)
- `panel-ui/src/hooks/useMailLogs.ts` (real hook)
- `panel-ui/src/shells/user/mail/tabs/LogsTab.tsx` (real page)
- `panel-ui/src/shells/user/mail/tabs/LogsTab.test.tsx` (new)

### UI sketch

Table with server-side pagination: columns = Timestamp, From, To, Size, Event(s) (truncated), Level (color tag). Filters bar: date range, sender, recipient, event-type multi-select, level multi-select. Row click → drawer with full trace event chain.

### Security

User-scoping is the load-bearing check. Test fixture: userA's logs must not be visible to userB. Enforce at panel-api level (DO NOT trust client-side filter parameters). Reference: security-review skill.

### Verification

```bash
go test ./panel-agent/internal/commands/... -run TestLogsQuery
go test ./panel-api/internal/api/... -run TestMailLogs_Scoping  # must verify user A cannot see user B's logs
npm --prefix panel-ui run test -- LogsTab
# Live: send a few test messages, refresh Logs tab, verify events appear with correct pagination + filters
```

### Exit criteria

- Logs tab shows outbound + inbound events for user's domains.
- Filters work (date range, sender, event type).
- Scoping test passes (cross-user leak prevented).
- Pagination honors Stalwart's native cursor/offset (whichever x:Log JMAP supports).
- Rebased, tests green.

---

## Step 8 — E2E + live smoke + runbook

**Model tier**: default. **Branch**: `m6.5/email-features/08-e2e-runbook`. **Depends on**: Steps 2-7 all merged to `m6.5/email-features` integration branch.

### Context brief

End-to-end verification on live VM 192.168.100.150 + Playwright E2E spec + runbook for operators.

### Files to touch

- `e2e/tests/m6.5-email-features.spec.ts` (new — one journey per tab: Mailboxes (regression check), Forwarders (create alias + external + verify), Autoresponders (toggle + send + verify reply), Catch-All (set + send to nonexistent + verify), Disclaimer (enable + send + verify), Shared Folders (share + browse), Logs (send + query))
- `plans/m6.5-email-features-runbook.md` (new — operator-facing teardown/rollback/debug)
- `docs/BLUEPRINT.md` (extend — M6.5 row in the capability matrix)

### Tasks

- [ ] Run Playwright E2E in CI (Gitea act_runner host mode, per `feedback_act_runner_host_mode.md`).
- [ ] Live VM smoke on 192.168.100.150: run every feature end-to-end; record outbound message capture + delivery evidence in runbook.
- [ ] Write runbook: enable/disable per feature, manual cleanup queries, Stalwart CLI equivalents for each feature.
- [ ] Extend BLUEPRINT.md.

### Verification

```bash
npm --prefix e2e run test:ci  # all M6.5 specs green
ssh -p 2222 root@192.168.100.150 'jabali update -f'  # deploy integration branch
# Manual: run through runbook end-to-end, verify every step
```

### Exit criteria

- All E2E specs green in CI.
- Live smoke documented (dated entries in runbook).
- BLUEPRINT.md updated.
- Rebased on `origin/main`.

---

## Step 9 — ADR acceptance + memory + squash-merge

**Model tier**: default. **Branch**: `m6.5/email-features` integration branch merged to `main`.

### Context brief

Flip ADR-0051 + ADR-0052 status to ACCEPTED, update memory index, merge integration branch to `main` (fast-forward if possible, otherwise squash-merge with full commit log preserved in message body).

### Tasks

- [ ] Flip `docs/adr/0051-m6.5-email-features-stalwart-as-truth.md` status to ACCEPTED.
- [ ] Flip `docs/adr/0052-disclaimer-sieve-vs-mtahook.md` status to ACCEPTED (body already records the spike outcome from Step 6).
- [ ] Update memory: remove `project_plan_m6_5_email_features.md` (in-progress marker), add `project_m6_5_shipped.md`, update `MEMORY.md` index.
- [ ] Update `docs/BLUEPRINT.md` "Sources of truth" footer date.
- [ ] Fetch latest `origin/main`, rebase integration branch, re-run Go + TS + E2E tests.
- [ ] Tag release: `git tag m6.5-shipped`.
- [ ] Present final branch SHA list to user; WAIT for explicit "push to main" before dispatcher publishes.

### Exit criteria

- All 9 steps on `main`.
- ADRs 0051 + 0052 ACCEPTED.
- Memory reflects SHIPPED status.
- Live VM 192.168.100.150 on post-M6.5 main with all features green.

---

## Cross-step invariants (verify after EVERY step)

1. `go build ./...` → 0 errors
2. `go test ./...` → all pass
3. `npm --prefix panel-ui run build` → 0 TS errors
4. `make migrate-up && make migrate-down && make migrate-up` cycles clean on dev DB
5. `/jabali-panel/mailboxes` still renders (legacy URL backward-compat via Step 1 redirect)
6. Existing M6 mailbox CRUD unaffected (regression spec on every PR)
7. No M6 DNS record churn (DKIM TXT, MX, SPF all unchanged in `dns_records`)
8. `feedback_verify_wire_contract.md`: every new hook reads actual `panel-api/internal/api/*.go` handler for envelope shape `{data, total, page, page_size}`

---

## Anti-patterns (do NOT do)

- **Don't dispatch agents to implement a well-specified step**; ref `feedback_no_agents_for_well_specified_steps.md`. Steps 2-4 + Step 7 are mechanical enough for inline execution.
- **Don't migrate-seed from app-populated tables**; ref `feedback_migration_data_seed_ordering.md`. Step 1 migrations are schema-only; any seeding (like default disclaimer text) goes in `panel-api` first-boot.
- **Don't push directly to `main`**. All waves land on `m6.5/email-features` integration branch first; umbrella merge to `main` only in Step 9.
- **Don't mock-only test the panel↔agent wire**; ref `feedback_cross_boundary_contracts.md`. Every new agent command needs a golden JSON fixture + real-UDS test.
- **Don't enumerate schema properties by name without verifying KIND**; ref `feedback_schema_enumerate_kinds_not_names.md`. Check `plans/m6.5-email-features-research.md` for tagged-enum vs bare-string shapes before writing JMAP request bodies.
- **Don't leave partial blueprint steps on main**; ref `feedback_no_partial_blueprint_to_main.md`. Integration branch stays closed until Step 9.
- **Don't trust fetch-only sync**; ref `feedback_fetch_rebase_before_deploy.md`. Every final report MUST include post-rebase re-test confirmation.

---

## Execution order summary

```
Wave A:     [ Step 1 foundation ]  (serial)
Wave B:     [ 2 catchall | 3 autoresponders | 4 shared-folders ]  (parallel)
Wave C:     [ 5 forwarders | 6 disclaimer | 7 logs ]  (parallel)
Wave D:     [ Step 8 e2e+runbook ] → [ Step 9 ADR+memory+merge ]  (serial)
```

9 steps · 4 waves · 2 ADRs · ~12 agent commands · 4 new DB tables + 3 domain columns · 7 tab pages (6 new + 1 extracted) · 0 new system packages · 0 external deps.

---

## Plan review — adversarial (opus)

Reviewed 2026-04-23. 2 CRITICAL + 2 HIGH + 4 MEDIUM found; all folded in above before dispatch:

- **CRIT 1** (router/reconciler parallelism collision) → fixed via `routes_m65.go` + `reconciler/phases/` registry pattern in Step 1.
- **CRIT 2** (migration numbering) → flipped to 000060–000063; plan anchors `000059_domains_is_panel_primary` as last on main.
- **HIGH 3** (ADR-0051 framing inverted DB-as-truth) → rewritten to "DB-as-truth, M6 pattern continued".
- **HIGH 4** (MtaHook fallback re-opens TCP that M25 closed) → Spike B added; TCP path forbidden; deferral to M6.6 is explicit outcome if unix:// not supported.
- **MED 5** (forwarders uniqueness wrong for aliases) → generated-column `alias_key` for cross-mailbox alias uniqueness.
- **MED 6** (sieve-concat was deferred — schema already answered it) → locked to concat; one script per mailbox named `jabali-fwds-<mailbox-id>`.
- **MED 7** (per-domain disclaimer scripts scalability) → noted as Spike A sub-case; verify all-active vs first-match before committing.
- **MED 8** (logs pagination shape undefined) → verify x:Log/query JMAP method's offset-vs-cursor shape from schema BEFORE coding Step 7.

Readiness: **GREEN** (post-fixes).
