# rizoma-mail — Changelog

Newest entries first. Never rewrite history — prepend only.

## [0.8.1] — 2026-09-27 — report_out dedupe fix

### Fixed
- `InsertDMARCReportOut` omitted `report_domain`: dedupe never matched
  and every run resent (caught live — same report queued twice to
  Google). Prod row repaired in place. Regression test pins the
  insert→find roundtrip and the UNIQUE duplicate refusal.

## [0.8.0] — 2026-09-27 — DMARC aggregate report sending

### Added — `maild dmarc send-reports` (daily)
- Builds one RFC 7489 aggregate report per From domain per UTC day
  from `inbound_auth`, discovers rua from live DMARC DNS, honors rua
  size limits, assembles gzip+MIME, enqueues via normal outbound
  (DKIM-signed). Skips: no rows, no rua, unresolvable policy, already
  sent (UNIQUE domain+day). `--domain`/`--date` overrides.
- Migration 8 (`header_from` on inbound_auth — envelope can't
  substitute for grouping; `report_domain` + dedupe key on
  reports_out). MX now records the From domain per message.
- Self-validating serialization: our XML re-parses via our own
  aggregate parser with matching totals (test).
- Shipped systemd timer (`rizoma-maild-dmarc-reports`, daily 01:30
  UTC): published to repo, installed+enabled by install.sh on fresh
  installs.
- Deliberately out: failure (ruf) reports, empty-domain reports.

## [0.7.0] — 2026-09-27 — DMARC rua parsing

### Added — aggregate report ingestion (`maild dmarc reports`)
- Scans INBOX(es) for provider reports (Google/Microsoft/Yahoo…),
  extracts XML directly, from `.zip`, or from `.gzip`, parses RFC 7489
  Appendix C, stores idempotently (UNIQUE report_id; re-scans store
  nothing), prints per-report digests (msgs/pass/fail + failing IPs)
  plus grand totals. Meant for cron (weekly). `--account` narrows;
  default covers all local accounts.
- Detection is by content (valid aggregate XML), not sender
  allowlists; non-reports skipped silently; corrupt archives skipped;
  8MB part cap; new `go-message` dependency (same author family as the
  SMTP stack).
- Migration 7 (`report_id`, `message_id` on `dmarc_reports_in`); new
  store methods; docs/TEST-PLAN updated.
- Deliberately NOT in this release: *sending* aggregate reports for
  others' domains (needs per-domain send scheduling — next).

## [0.6.0] — 2026-09-27 — aliases, forwarding, SRS

### Added — groups wired end to end (were dead schema)
- `maild group add/members/list/remove` with validation (valid
  addresses, no shadowing accounts, hygiene warnings for unknown local
  members). `domain onboard --aliases info,support` creates them to
  postmaster.
- MX + submission both expand groups (nested, cycle-safe, depth-capped):
  local members deliver (quota + quarantine apply), unknown members
  drop loudly without failing the message, unknown senders still get
  errors on direct sends.
- MX RCPT accepts group and SRS-bounce addresses (DB errors tempfail).

### Added — SRS (RFC-compatible shape, self-verified)
- `SRS0=HHHH=TTTT=domain=user@forwarder` (HMAC-SHA1, 21d TTL, 64-char
  guard, rotation via previous secret). External forwards carry the
  SRS sender (SPF survives); bounces return and relay with null sender.
  No secret = external forwarding refused loudly (fail closed), never
  unsigned. Forwarded copies stay byte-pristine (original DKIM
  verifiable downstream); local copies get the AR stamp.
- `RIZOMA_MAIL_SRS_SECRET[/_PREVIOUS]`; fresh installs auto-generate
  into the env file (never rotated silently).
- Proven: roundtrip/tamper/expiry/rotation/length unit tests; MX
  integration (local+forward+SRS-relay+forgery rejection) against a
  real store.

## [0.5.3] — 2026-09-27 — DNSBL skips non-routable peers

### Fixed
- DNSBL no longer queries loopback/private/link-local/CGNAT peers
  (RBLs attest public IPs only — Spamhaus PBL even "lists" 127.0.0.1,
  which blocked loopback operability testing live). Such peers are
  trusted-local by topology. Bonus find: the live refusal proved the
  full DNSBL path (lookup → listed → `550 5.7.1`) works end to end.

## [0.5.2] — 2026-09-27 — IMAP literal framing fix

### Fixed
- FETCH literals were framed illegally: the closing `)` sat on the
  marker line (`(... {n})`) instead of after the octets. Strict parsers
  (Python imaplib, and likely Thunderbird) abort on this — every literal
  response on every FETCH was affected, including the previously
  "working" paths. Marker lines now end with `{n}`; `)` closes after
  the octets.

## [0.5.1] — 2026-09-27 — IMAP section fetch fix (Thunderbird root cause)

### Fixed
- `BODY[HEADER.FIELDS (...)]` / `HEADER.FIELDS.NOT` / `BODY.PEEK[TEXT]`
  returned EMPTY responses (only bare `BODY[HEADER]`/`BODY[TEXT]`/`BODY[]`
  matched). This is Thunderbird's standard list-row fetch — the likely
  reason it showed no headers. Full section parser with request-echo
  labels, folding-preserving field filter, PEEK-correct labels.
- UID commands ran translation BEFORE mailbox refresh: any UID command
  on mail that arrived after SELECT failed `BAD Invalid UID set`.
  Refresh now precedes translation.
- Added `docs/TEST-PLAN.md`: implemented+tested vs implemented+
  unverified tracker with a per-release verification runbook.

## [0.5.0] — 2026-09-26 — client-domain onboarding as one command

### Added — `maild domain onboard <domain>`
- One command configures a client domain end to end: domain row (+
  mail hostname, default `mx.<domain>`), DKIM key, RFC-mandated
  postmaster (generated password printed once), exact copy-paste DNS
  block (MX/A/SPF/DKIM/DMARC/MTA-STS/autoconfig/TLS-RPT/SRV), then
  probes each prerequisite as ok/pending/fail. Idempotent resume.
- New `maild domain verify <domain>`: read-only gate — DNS, all ports
  (25/587/465/993/80), TLS leaf SAN+expiry; exit 0 only when everything
  passes. Restart the daemon after DNS, then verify.
- `domain add` accepts `--hostname`; `domain list` shows it.

### Added — multi-domain ACME/SNI
- `domains.mail_hostname` (migration 6); ACME whitelists, prefetches,
  and serves every mail hostname with per-SNI selection; MTA-STS
  policy lists all MX hosts; autoconfig maps each email domain to its
  own mail hostname (unknown domains fall back to primary). HELO stays
  the primary hostname (PTR alignment).

## [0.4.0] — 2026-09-26 — Batch B antispam + Batch C deliverability

### Added — DNSBL (Batch B1)
- Inbound MX refuses peers listed in configurable blocklists at DATA
  (5xx). Default `zen.spamhaus.org`; override via
  `RIZOMA_MAIL_DNSBL_ZONES`, disable via `RIZOMA_MAIL_DNSBL_ENABLE=0`.
  Whitelist codes (127.0.0.10/11), IPv6, and DNS errors fail open.

### Added — Authentication-Results stamping (Batch B2, RFC 8601)
- Verified SPF/DKIM/DMARC results are now stamped into delivered mail,
  so Thunderbird filters and Sieve can act on them (previously
  logs/DB only).

### Added — Rspamd hook (Batch B3)
- Optional `RIZOMA_MAIL_RSPAMD_URL` (+password): POSTs raw mail to
  `/checkv2`; reject→5xx, softer actions→Junk. Every failure mode
  fails open to the other layers. Empty URL (default) disables.

### Added — submission Message-ID/Date stamping (Batch C1, RFC 6409 §8)
- Submission adds missing Date/Message-ID (never modifies present
  ones, never touches transit mail). Gmail no longer has to add
  SMTPIN_ADDED_MISSING for us.

### Added — port 465 implicit TLS (Batch C1, RFC 8314)
- New submission listener with mandatory TLS config (refuses to start
  without a certificate — no downgrade trap). Same backend/identity.

### Added — IMAP IDLE + SPECIAL-USE, live-arrival refresh (Batch C1)
- IDLE (RFC 2177) with EXISTS push on arrivals; CAPABILITY advertises
  IDLE SPECIAL-USE (LIST already emitted the flags).
- Fixed the Thunderbird stale-inbox root cause: SEARCH/FETCH/STORE now
  pick up mail that arrived after SELECT (append-only, UIDs stable via
  dovecot-uidlist). Proven by protocol test.

### Added — MTA-STS + Thunderbird autoconfig on :80 (Batch C2)
- The ACME challenge server now also serves `/.well-known/mta-sts.txt`
  (mode testing; flip to enforce after a quiet period) and Thunderbird
  `config-v1.1.xml` derived from the `autoconfig.<domain>` Host.
- Operator DNS still needed (documented): `_mta-sts.<host>` TXT,
  `autoconfig.<domain>` A record, `_smtp._tls` TLS-RPT record,
  `_imaps/_submission` SRV records.

### Known limits (not in this release)
- DMARC aggregate reports: neither sending (as receiver) nor parsing
  (as sender) rua reports yet.
- Aliases/groups stored but not expanded (needs SRS for external
  forwarding — deliberately untouched).
- ARC validation/sealing, SMTPUTF8, Sieve, UIDPLUS/MOVE: future.

## [0.3.0] — 2026-09-26 — Batch A audit: enforcement, NDRs, AUTH guard

### Added — DMARC enforcement (was computed, logged, then ignored)
- `p=reject` is refused at DATA time (5xx); `quarantine` files into the
  recipient's Junk folder (auto-listed by IMAP); `temperror` fails open
  with a loud log (go-smtp can only 5xx DATA errors — bouncing legit
  mail on DNS hiccups is worse). Gated by `RIZOMA_MAIL_DMARC_ENFORCE`
  (default on, `0` = log-only). Quarantine bypasses quota so spam can't
  fill a mailbox into a DoS.

### Added — NDRs on permanent failure (RFC 3464)
- Outbound rows that exhaust retries now generate a delivery-status
  notification to the envelope sender (null sender + Auto-Submitted,
  original headers only, never the body). Bouncing NDRs die silently
  by construction (no loops). Loop-safety and null-sender rules proven
  by test. Side fix: `Enqueue` maps empty account/domain IDs to NULL
  (they are foreign keys — `""` violated the constraint).

### Added — AUTH brute-force guard (SMTP + IMAP)
- Shared sliding-window guard: 10 failures/account/5min, 100/min
  listener-wide; success resets history. Unknown users burn a dummy
  bcrypt so rejects cost the same as wrong passwords (no timing
  oracle), and all client-facing errors are one generic message (no
  user enumeration via SASL/IMAP errors).

### Added — panic recovery (MX DATA, submission DATA, queue worker)
- Poison messages become 5xx/deferred instead of killing the daemon.
  Proven by test (nil-backend session, panicking signer).

### Added — RCPT-time recipient validation
- Unknown local-parts are rejected at RCPT (was: accepted, failed late
  at DATA). Case-insensitive. e2e updated to register the test user
  (mirrors production syncBackends).
- Permanent MX rejections (unknown domain/recipient, DMARC reject) now
  carry explicit 5xx codes via SMTPError. go-smtp defaults every plain
  error to 451, which made senders pointlessly retry mail we will never
  accept.

## [0.2.2] — 2026-09-26

### Fixed — DKIM interop audit (both directions were broken)
Real-world signatures always failed verification while self-roundtrips
passed, and our own signatures failed at Gmail (spam placement). Root
causes, all in canonicalization:
- `b=` was REMOVED instead of emptied (signer and verifier alike).
  RFC 6376 §5.4.1 requires the tag kept with an empty value — and the
  signature header hashed with NO trailing CRLF (also fixed).
- Relaxed header canon kept the space after the colon; RFC 6376 §3.4.2
  deletes WSP around it (`from:joe`, not `from: joe`).
- Leading whitespace was stripped by net/mail parsing; values are now
  kept verbatim (parser rewritten to preserve raw + unfolded forms).
- Header algorithm was ignored (always relaxed); `c=` is now parsed
  properly (default simple/simple, single name implies simple body).
- `h=` names not trimmed (RFC-style `Received : From` failed).
- Duplicate headers collapsed (first won); instances now consumed
  last-to-first per RFC 6376 §5.4.1 (added-Received transit case).
- `l=` ignored, `x=` unenforced, `v=`/`q=` unchecked — all handled.
- Folded base64 (CRLF+WSP inside b=/bh=) broke decoding; whitespace is
  stripped before decode.
- Proof: RFC 6376 Appendix A third-party vectors (simple/simple,
  added-Received) now PASS, plus relaxed roundtrip, refolded
  roundtrip, and tamper rejection. No test previously covered interop.
- Signer now verified by the go-msgauth reference implementation; a
  pinned signer vector (fixed key + timestamp) locks the exact bytes.
- Submission server no longer logs a bogus "TLS not configured"
  warning when TLS comes from a provider config (check moved after
  options).

## [0.2.1] — 2026-09-25

### Fixed
- ACME `GetCertificate` no longer kills handshakes without a whitelisted
  SNI. Legacy MTAs on port 25 often send no SNI; strict policy turned
  those into `tlsv1 alert internal error` and silently lost inbound TLS.
  The provider now serves the freshest known-good leaf as fallback (and
  keeps it fresh on every successful issuance/renewal). Found live: a
  loopback probe without SNI failed while Thunderbird with proper SNI
  worked.

## [0.2.0] — 2026-09-25 — native ACME, Caddy removed

### Added
- Native ACME in `maild` (`internal/mailtls`, zero new dependencies —
  `golang.org/x/crypto/acme/autocert`): `RIZOMA_MAIL_ACME_ENABLE=1`
  obtains and auto-renews a public Let's Encrypt certificate for
  `RIZOMA_MAIL_HOSTNAME` via HTTP-01 on port 80. Startup prefetch fails
  fast on misconfiguration (DNS, port 80, firewall); a 24h monitor logs
  leaf issuer/expiry and warns loudly when a static cert nears expiry.
  When ACME is enabled it is authoritative and static files are ignored.
- `WithTLSConfig` option on the MX/submission/IMAP servers; existing
  `NewServer` signatures unchanged, no test churn.
- Installer creates `acme-cache/` (0700, maild-owned) and documents the
  ACME env block in the default env template.

### Removed
- Caddy purged from the mail VPS: it was installed but never running
  (no process, nothing on :80/:443, dead :8080 proxy target) and owned
  no part of issuance or renewal. There is no reverse proxy on the mail
  host by design — MX/IMAP/submission terminate TLS in `maild` itself.

## [0.1.2] — 2026-09-24

### Added
- IMAP `UID SEARCH` / `UID FETCH` / `UID STORE` (RFC 3501 §6.4.8) via
  UID→sequence translation; `ENVELOPE` item; bare `BODY[]`/`BODY.PEEK[]`.
  Real clients (Thunderbird, Vesper agent) operate in UID space and fetch
  ENVELOPE — without these every such request got BAD or empty.

### Fixed
- `SEARCH SEEN/UNSEEN` never matched: flag map keys are bare (`Seen`),
  the check used backslash-prefixed (`\Seen`).
- Maildir ownership rule documented: CLI-created mailboxes must be
  `chown`ed to the service user (or run CLI as that user).

## [0.1.1] — 2026-09-24

### Fixed
- IMAP `AUTHENTICATE` rejected lowercase mechanism names (e.g. Thunderbird's
  `AUTHENTICATE plain`): the `PLAIN ` prefix check was case-insensitive but
  the strip was case-sensitive, breaking base64 decode for every such client.
  Found by live Thunderbird test against 0.1.0.

## [0.1.0] — 2026-09-24

First release. Standalone MTA extracted from `rizoma-webpanel`.

### Added
- `maild` binary: MX `:25`, submission `:587` (AUTH+TLS), IMAP `:993`,
  background outbound worker (30s/25), SQLite store, Maildir.
- CLI: `serve`, `domain add/list`, `account add/list`, `dkim show`
  (prints the exact DNS TXT), `queue stats`, `--version`.
- DKIM key auto-creation + outbound signing at delivery time.
- Split-horizon DNS (mesh suffixes + upstream).
- E2E proof test (`e2e/`): TLS submission, MX receipt, IMAP read,
  external enqueue, DKIM signature — all against real loopback listeners.

### Fixed (inherited bugs found by extracting)
- Ambiguous `status` column broke `GetEmailAccount` → all SMTP/IMAP AUTH
  silently dead (same bug still live in webpanel).
- IMAP `Serve` shadowed-`err` swallowed bind failures → SIGSEGV.
- SMTP `DATA` silently dropped malformed recipients → fails loudly now.
- 3 test bugs (RCPT-relay assertion, 2× missing Maildir init).

### Known gaps (see docs/GAPS.md)
- No spam filtering, sieve, DMARC enforcement/reports, greylisting,
  ACME, rate-limit persistence, web UI, metrics, backup automation.
