# M6.5 Email Features — Stalwart Integration Research

**Date:** 2026-04-23
**Source:** `resources/schema/schema.json.gz` @ stalwartlabs/stalwart `main`

All 6 features map to existing Stalwart primitives. No new server code needed. Agent commands pattern matches existing M6 `mailbox_*.go`.

## Feature Integration Map

| UI Tab | Stalwart Surface | Scope | Verdict |
|---|---|---|---|
| Mailboxes | Already shipped (M6) | per-domain | — |
| **Forwarders** | Two-flavor: `x:UserAccount.aliases` + user sieve `redirect` | mailbox-level | Native |
| **Autoresponders** | `VacationResponse` JMAP singleton (RFC 8621 §8) | per-mailbox | Native |
| **Catch-All** | `x:Domain.catchAllAddress` string | per-domain (one value) | Native |
| **Disclaimer** | System sieve script, `replace` + `body` extensions | per-domain | Native |
| **Shared Folders** | `Mailbox.shareWith` (Principal→MailboxRights map) | per-mailbox | Native |
| **Logs** | `x:Log` + `x:Trace` JMAP queries | server-wide, filter by sender/recipient | Native |

## Key Schema Extracts

### Forwarders — Two Concepts, Two Mechanisms

Stalwart separates "alias" (mail for X also delivers here) from "forward" (mail for X goes elsewhere):

```
x:EmailAlias { name: "sales", domainId: <Domain>, enabled: bool }
  → owned by x:UserAccount.aliases[] — receive-also alias within the domain

x:SieveUserScript { contents: "redirect :copy ...;", isActive: true }
  → per-user sieve with `redirect` action — forwards to external address
```

Jabali UI "Forwarders" tab maps to both: a single table with a "Type" column (Alias vs Forward). Create dialog asks source mailbox and target (local → alias; external → sieve).

### Autoresponders — JMAP VacationResponse (RFC 8621 §8)

```
VacationResponse (singleton per account) {
  isEnabled: bool
  fromDate: utcDateTime?
  toDate: utcDateTime?
  subject: string?
  textBody: string?
  htmlBody: string?
}
```

One per mailbox. Direct JMAP VacationResponse/set.

### Catch-All — Single Domain Field

```
x:Domain.catchAllAddress: emailAddress | null
```

Per-domain, at most one. `stalwart-cli update x:Domain <id> catchAllAddress foo@bar.com`.

### Disclaimer — System Sieve Script, Body-Append

Stalwart sieve capabilities include `body`, `replace`, `editheader`, `envelope`, `foreverypart`. Script pattern (templated per-domain):

```sieve
require ["envelope", "body", "replace", "foreverypart"];

if envelope :localpart "from" "<domain-local-users-pattern>" {
  foreverypart {
    if header :mime :contenttype :is "content-type" "text/plain" {
      replace :subject "${original}
  \n\n-- \n<disclaimer-text>";
    }
  }
}
```

Script lives as `x:SieveSystemScript` (name=`jabali-disclaimer-<domain>`). Reconciled per-domain on enable/disable/text-update. Active scripts run at outbound `data` stage.

Risk: `replace` on body requires sieve-MIME extensions. Verify live before committing to approach — may need MtaHook fallback for HTML parts.

### Shared Folders — Mailbox.shareWith (JMAP Native)

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

JMAP Mailbox/set with `shareWith` patch. Browse target mailbox + pick principals + rights. Dovecot-style ACL equivalent.

### Logs — JMAP x:Log + x:Trace

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

JMAP query with filter: `{ from: "foo@bar.com" }` or `{ to: "..." }` or `{ from_date, to_date }`. Paginated list. Renders per-message event chain.

## Agent Command Mapping

Existing M6 pattern in `panel-agent/internal/commands/mailbox_*.go` (each wraps a stalwart-cli JMAP call). M6.5 extends:

```
panel-agent/internal/commands/
  forwarder_alias_create.go       (x:UserAccount + aliases patch)
  forwarder_alias_delete.go
  forwarder_sieve_set.go          (x:SieveUserScript create/update with redirect body)
  forwarder_sieve_delete.go
  autoresponder_get.go            (VacationResponse/get)
  autoresponder_set.go            (VacationResponse/set)
  domain_catchall_set.go          (x:Domain update catchAllAddress)
  domain_catchall_clear.go
  domain_disclaimer_set.go        (x:SieveSystemScript upsert)
  domain_disclaimer_clear.go
  mailbox_share.go                (Mailbox/set shareWith patch)
  mailbox_unshare.go
  logs_query.go                   (x:Log + x:Trace query with filter)
```

## Data Model (Panel-API)

New tables (all reconcilable from Stalwart → jabali is cache, not source-of-truth):

```
email_forwarders       (id, mailbox_id, type enum('alias','external'), target, enabled, created_at)
email_autoresponders   (mailbox_id PRIMARY KEY, enabled, from_date, to_date, subject, text_body, html_body)
domain_catchall        (domain_id PRIMARY KEY, target_email, enabled)
domain_disclaimers     (domain_id PRIMARY KEY, enabled, text)
mailbox_shares         (id, mailbox_id, shared_with_principal_id, rights JSON)
mail_logs              (not stored — passthrough query to Stalwart)
```

All tables reconciled by reconciler `migrateBootstrapShape`-style: jabali truth → Stalwart apply, with self-heal of drift.

## Open Questions (to resolve before blueprint)

1. **Sieve MIME body-replace on HTML parts** — verify on live VM before locking disclaimer on sieve. If sieve can't reach HTML parts portably, fall back to MtaHook (body rewrite via HTTP callback — panel-api hosts endpoint).
2. **Cross-domain table UX** — existing Mailboxes tab already cross-domain. Same pattern applies. Create dialogs pick domain.
3. **Logs pagination shape** — confirm x:Log/x:Trace JMAP supports cursor or offset paging; size of Stalwart log buffer (retention window).

## References

- Stalwart schema (source of truth): `resources/schema/schema.json.gz` in stalwartlabs/stalwart
- JMAP RFC 8621 §8 (VacationResponse)
- RFC 8554 / JMAP Sharing (Mailbox.shareWith)
- Stalwart sieve capabilities: schema `SieveCapability` enum
