# M33.2 — Mail YARA Async Scanner Runbook

ADR-0079. Async post-delivery scan of Stalwart mailboxes via JMAP. Off by
default. Enable via admin Security → Malware → Settings tab.

## Architecture (one-liner)

Reconciler tick inside panel-api (5-min cadence) → enumerate tenants via
JMAP `Principal/query` → per-account `Mailbox/query` (Inbox unless
`mail_scan_all_folders`) → `Email/query` since cursor → `Email/get` with
attachment metadata → blob-download → yr-scan → on-hit `Email/set` move
to "Malware" mailbox + emit `malware_quarantine_added` event (source=yara)
→ M14 fans out to admin channels.

> **Stalwart's side looks empty — and that's expected.** Reviewers
> investigating mail malware coverage often probe Stalwart's registry
> with:
>
> ```
> stalwart-cli query x:MtaHook   --json   # → []
> stalwart-cli query x:MtaMilter --json   # → []
> ```
>
> Both come back empty by design (ADR-0079). M33.2 deliberately does
> NOT use Stalwart's synchronous filter mechanisms — Stalwart's milter
> + MtaHook are TCP-only (verified in upstream Rust source) and the
> webshell-via-mail threat model is human-in-loop, so async tag-and-
> quarantine catches the threat before exploitation at zero SMTP-time
> latency and zero new daemon. Wire integration lives in panel-api at
> `internal/mailscan/`; observe via
> `journalctl -u jabali-panel -g mailscan` (NOT via Stalwart logs or
> registry).

## Operator checklist

```
sudo -u jabali curl -s --unix-socket /run/jabali-panel/api.sock \
  http://localhost/api/v1/admin/security/malware/settings | jq
# expect mail_scan_enabled: false (default)
```

To enable from CLI:

```
mariadb -e "UPDATE jabali_panel.malware_settings SET mail_scan_enabled = 1 WHERE id = 1"
# panel-api picks this up on the next tick (≤5 min); no restart
```

UI: Admin → Security → Malware → Settings → toggle "Enable mail
attachment scanning".

## Health checks

```
# Tick liveness — log line every 5 min
journalctl -u jabali-panel -t panel-api -g 'mailscan tick'

# Per-mailbox cursor + counts
mariadb -e "SELECT account_id, mailbox_id, scanned_count, hit_count, failure_count, scanned_at FROM jabali_panel.mail_scan_state ORDER BY scanned_at DESC LIMIT 20"

# DLQ — anything > 0 means a tick failed something
mariadb -e "SELECT reason, COUNT(*) FROM jabali_panel.mail_scan_failures WHERE attempted_at > NOW() - INTERVAL 1 DAY GROUP BY reason"
```

## Common failure modes

- **`yr` binary missing** — installs ship via `install_malware_stack`. If
  M33 stack wasn't installed, the tick records a `scan_attachment` /
  `no YARA rule sources installed` failure per attachment. Fix: run
  `jabali repair --auto` or re-run `install.sh` (idempotent).
- **`stalwart-admin.token` unreadable** — file is `0640 jabali:jabali-mail`
  and panel-api runs as `jabali` (owner). If `chown` drifted, the tick
  fails with `read stalwart admin token: permission denied`. Fix:
  `chown jabali:jabali-mail /etc/jabali-panel/stalwart-admin.token; chmod 0640`.
- **Stalwart down** — tick errors with `GET /jmap/session: connection
  refused`. Self-heals on next tick when Stalwart returns. No action
  required.
- **Quarantine mailbox renamed by user** — cache miss → `MailboxExists`
  returns false → re-create on next hit. 24h revalidation via
  `quarantine_mailbox_verified` ensures stale ids drift out within a day
  even without a hit.
- **Lock contention** — flock at `/run/jabali/mail-scan.lock`. Two ticks
  can't overlap; the late one logs "previous tick still running" + skips.
  Persistent skipping = a tick wedged on a single huge attachment;
  remediate by raising `mail_scan_timeout_sec` or trimming
  `mail_scan_max_attachment_mb`.

## EICAR smoke

Send EICAR (`X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*`)
as an attachment to a panel mailbox. Within ≤5 min:

1. Email moves to `Malware` mailbox (visible in webmail + JMAP).
2. `mail_scan_state` increments `hit_count` for that mailbox.
3. `malware_events` row appears with `source=yara`, `event_type=file_hit`,
   `raw_json.mail_id` populated.
4. `malware_quarantine` row appears with
   `quarantine_path = jmap://<accountId>/<emailId>`,
   `original_path = smtp:<from>/<filename>`.
5. M14 admin notification fires (in-app bell at minimum; email/ntfy/slack
   if configured).

## Disabling

Toggle Settings → "Enable mail attachment scanning" off, OR:

```
mariadb -e "UPDATE jabali_panel.malware_settings SET mail_scan_enabled = 0 WHERE id = 1"
```

The tick still fires every 5 min but short-circuits on the settings
read. To stop the goroutine entirely, restart panel-api with the
disabled setting in place.

## Related

- ADR-0072 (M33 filesystem stack) — same rule sources, same UI surface.
- ADR-0079 — full design rationale (async vs MTA-hook trade-offs).
- `panel-api/internal/mailscan/` — implementation.
- migration `000093_mail_yara_async.up.sql` — schema (renumbered 2026-04-29 from 000092 after collision with M38 `000092_alter_domains_ghost_state`).
