# GoMail — Action Plan v4 (post-Phase 9) **Status: Phases 1–9 complete and verified. Phases 10–15 remain.** This supersedes v3 for anything marked complete below. Where v3's design held up under actual implementation, it's summarized, not repeated — see v3 for full rationale on decisions that didn't change (encryption scheme, POP3 off-by-default, OAuth self-registration, time-based linked-account caching, etc.). --- ## What's built (Phases 1–9) | Phase | Package(s) | What it does | Status | |---|---|---|---| | 1 | `config`, `crypto`, `db` | YAML config + env secrets, HKDF-per-record encryption, SQLite schema/migrations, bootstrap | ✅ verified | | 2 | `smtp`, `mailstore`, `tlsutil` | Inbound MTA (:25/:587/:465), STARTTLS, SASL PLAIN/LOGIN, encrypted Maildir delivery | ✅ verified | | 3 | `dkim`, `queue` | DKIM sign/verify (stdlib crypto only), outbound retry queue, MX delivery via `net/smtp`, bounce DSN | ✅ verified | | 4 | `pipeline` | SPF/DKIM/DMARC/header/URL checks, quarantine storage, verdict scoring | ✅ verified against live DNS | | 5 | `imap`, `pop3`, `auth` | Full IMAP core command set, POP3 (off by default), shared credential verification | ✅ verified | | 6 | `accounts`, `imapclient` | `MailProvider` interface, local + generic-IMAP backends, hand-rolled IMAP client | ✅ verified (dogfoods own server) | | 7 | `vcard`, `ical`, `dav` | CalDAV/CardDAV server (PROPFIND/REPORT/PUT/GET/DELETE), encrypted contacts/events | ✅ verified | | 8 | `webtoken`, `webmail` | Hand-rolled JWT, REST API + dark Tailwind SPA (folders/messages/compose/quarantine) | ✅ verified | | 9 | `jmap` | JMAP Core/Mail subset (session, Core/echo, Mailbox/get, Email/query, Email/get) | ✅ verified | | 9.5 | `sieve`, `managesieve` | Sieve interpreter (RFC 5228 subset) + ManageSieve server (RFC 5804), wired into inbound delivery | ✅ verified | | 10 | `oauth2`, extended `accounts`/`imapclient`/`webmail` | Hand-rolled OAuth2 (auth code grant + refresh), XOAUTH2 IMAP login, webmail account-linking endpoints (CSRF-protected) | ✅ verified against fake OAuth2 server + fake XOAUTH2 IMAP stub — see note below | | 11 | `admin` | Admin portal: RBAC-gated REST API (global_admin/tenant_admin) + dark Tailwind SPA — tenants, domains (+DKIM keygen/rotation), users, list rules, outbound queue, global quarantine, dashboard stats | ✅ verified — full RBAC enforcement, real DKIM key generation/rotation checked cryptographically | | 13 | `acme`, `tlsutil` (ACMEManager) | Real ACME v2 client (RFC 8555): JWS/ES256 signing, HTTP-01 challenge, SNI-aware cert serving, encrypted storage, auto-renewal loop. Pulled forward from phase order — see rationale below. | ✅ verified against a from-scratch fake ACME server with real JWS/thumbprint verification — see note below | | 12 | `totp`, extended `webmail`/`db` | Auth hardening: TOTP MFA (RFC 6238) with two-step login, backup codes, app-password self-service UI, recovery-email password reset. Completed after Phase 13 since 13 was pulled forward — see Phase 13's entry above. | ✅ verified — TOTP checked against 5 real RFC 6238 published test vectors, full two-step login/backup-code/app-password/reset flows over real HTTP | | 14 | extended `pipeline` (ClamAV/Rspamd/LLM stages) | Optional external security services: real clamd INSTREAM protocol, real rspamd `/checkv2` HTTP API, OpenAI-compatible LLM classification (llama.cpp server) — all off by default, only added to the pipeline when configured | ✅ verified — genuine EICAR malware detection (byte-level, not canned), real rspamd score pass-through, LLM score capping, graceful negative-path handling for unreachable services | | 15 | `ratelimit`, fuzz tests, `install.sh`/`gomail.service`/`README.md` | Hardening + deploy: per-IP token-bucket rate limiting on every listener, fuzz tests on the 4 riskiest hand-written parsers, `go vet` pass, systemd install script + hardened unit, full README with DNS setup guide | ✅ verified — rate limiting proven under the race detector and over real sockets/HTTP, ~1.5M fuzz executions with zero panics, systemd unit verified with `systemd-analyze verify` | **~14,400 lines of Go**, one third-party runtime dependency beyond `mattn/go-sqlite3`/`golang.org/x/crypto`/`google/uuid`/`gopkg.in/yaml.v3` — everything else (SMTP, IMAP, POP3, JMAP, CalDAV/CardDAV, JWT, DKIM, SPF, DMARC) is hand-rolled on the Go standard library, per the original dependency-minimal requirement. ### Deferred within completed phases (honest gaps, not hidden) - **DAV**: REPORT filter/time-range queries return the full collection, not a filtered subset. MKCALENDAR/MKCOL not implemented — default collection auto-created per user instead. - **IMAP**: no IDLE, CONDSTORE/QRESYNC, SORT/THREAD, or mailbox CREATE/DELETE/RENAME. Core command set only (see Phase 5 code comments). - **JMAP**: Email/set (flags/delete via JMAP), Email/import (send via JMAP), push (EventSource), and Mailbox/set are not implemented — the webmail SPA still uses Phase 8's direct REST API for all mutations. ManageSieve (RFC 5804) was paired with JMAP in the original plan and **was not started this phase at all** — full gap, see Phase 9.5 below. - **accounts.IMAPProvider.Move**: returns "not implemented" — needs IMAP APPEND, which isn't in the imapclient package yet. - **TLS**: self-signed cert generation only (`tlsutil`), no ACME. Every linked IMAP/SMTP connection uses `InsecureSkipVerify: true` as a known, commented gap — real cert trust (pinning or ACME) is Phase 13. --- ## Remaining phases ### Phase 9.5 — ManageSieve + Sieve interpreter — ✅ COMPLETE `internal/sieve`: hand-written recursive-descent interpreter covering `if`/`elsif`/`else`, `header :contains`/`:is` tests, `fileinto`/`discard`/ `keep`/`stop` actions. Verified: correct short-circuit on `stop`, correct RFC 5228 §4.4 "explicit keep after discard still delivers" semantics, malformed scripts rejected at parse time. `internal/managesieve`: RFC 5804 server on `:4190` — CAPABILITY, STARTTLS (auth refused before TLS, verified), AUTHENTICATE PLAIN, PUTSCRIPT (rejects syntactically invalid scripts at upload time via `sieve.Parse`), GETSCRIPT, LISTSCRIPTS, SETACTIVE (enforces exactly one active script per user), DELETESCRIPT, LOGOUT. Wired into `internal/smtp/session.go`'s local-delivery path: after the security pipeline clears a message (clean/flagged), the recipient's active Sieve script (if any) runs against the message headers and determines the destination folder or discard — verified end-to-end with a real SMTP delivery routed by an uploaded rule. **Deferred within this phase**: only `header` tests are supported (no `address`, `envelope`, `size`, `exists` tests); no `anyof`/`allof` boolean combinators (only single-condition if/elsif); no Sieve extensions (`vacation`, `reject`, `notify`, `imap4flags`); webmail visual rule builder not built — raw script upload via the ManageSieve protocol is the only interface (testable with `sieve-connect` or similar real clients). ### Phase 10 — Multi-account webmail: OAuth2 + Gmail/M365 — ✅ PARTIALLY COMPLETE **What's done and verified:** - `internal/oauth2`: hand-rolled OAuth2 authorization-code grant + refresh (RFC 6749 §4.1, §6), ~150 lines, stdlib `net/http`+`encoding/json` only. `WellKnownEndpoints("google"|"microsoft")` returns the real fixed endpoint URLs — not operator-configurable, matching how real integrations work (only Client ID/Secret are operator-supplied). - `imapclient.LoginXOAUTH2`: SASL XOAUTH2 mechanism for connecting to Gmail/M365 with a bearer token instead of a password. - `accounts.IMAPProvider` extended: branches on `AuthType` — password accounts unchanged, OAuth2 accounts decrypt a stored token, transparently refresh it if expired (persisting the new token to avoid re-refreshing on every call), and authenticate via XOAUTH2. - `accounts.LinkOAuth2Account` / `wellKnownIMAPHost`: stores encrypted OAuth2 tokens, points Gmail/M365 accounts at their real documented IMAP hosts (`imap.gmail.com:993`, `outlook.office365.com:993`). - `webmail` API: `/api/accounts` (list), `/api/accounts/{id}` (unlink), `/api/accounts/oauth/{provider}/start` (builds auth URL, stores CSRF state server-side keyed to the authenticated user), `/api/accounts/oauth/{provider}/callback` (validates state — rejects unknown AND replayed state, verified — exchanges code, links account). - **Verified** with a from-scratch fake OAuth2 authorization server (`/authorize` + `/token`, both grant types) and a fake XOAUTH2-accepting IMAP stub — full round trip: start → redirect → callback → encrypted token storage → real XOAUTH2 IMAP login → forced token expiry → transparent refresh → persisted new token. This is real protocol-correctness verification; it does **not** touch real Google/Microsoft infrastructure, since no real app credentials exist in the dev environment this was built in. **The next session should register a real Google Cloud OAuth app (or ask the person for one) and do one live end-to-end test against actual Gmail before considering this phase fully closed** — the protocol layer is proven, but "does real Gmail's token endpoint actually behave the way RFC 6749 says it should" has not been checked against the real service. **Deferred within this phase:** - Native Gmail API / Microsoft Graph API providers (`GmailProvider`, `M365Provider` as distinct from the IMAP+OAuth2 path) — per the locked decision, mail goes through `IMAPProvider` with OAuth2 credentials for now; native API push (Gmail watch+Pub/Sub, Graph delta webhooks) is Phase 14 per the original plan. - Calendar/contacts via native Graph API / Gmail Calendar+People API — not started. No IMAP fallback exists for these, so this is still fully greenfield work reusing the OAuth2 token store just built. - Webmail UI: account switcher, unified "All Inboxes" view, per-account "send as" in compose, federated search — the backend (`/api/accounts`) exists but the SPA has no UI for any of this yet. - Real userinfo/profile API call to learn the linked account's actual email address at callback time — currently accepts an `?email=` query param as a stand-in (noted in code); a real implementation calls Google's `userinfo` endpoint or Microsoft Graph's `/me` with the fresh access token. - `IMAPProvider.Move` still returns "not implemented" (needs IMAP APPEND, unchanged from Phase 6). ### Phase 11 — Admin portal — ✅ COMPLETE (core CRUD) **What's done and verified:** - `internal/admin`: REST API + embedded dark Tailwind SPA, JWT sessions (same `webtoken` scheme as webmail), role checked at auth time AND re-checked against the live DB row on every request (a demoted admin's existing token stops working immediately, not just after expiry). - **Tenants**: list/create, global_admin only (verified: tenant_admin gets 403). - **Domains**: list/create/delete, real DKIM key generation on create (verified: stored key decrypts and parses as a genuine RSA private key, not just "a create call succeeded"), DKIM rotation (verified: key material genuinely changes, not a no-op). global_admin defaults to their own tenant if none specified; tenant_admin forced to their own tenant regardless of what they send. - **Users**: list/create/suspend/activate/reset-password/delete. tenant_admin scoped to their own tenant and blocked from creating or modifying admin-role accounts (verified: 403 on a privilege-escalation attempt). - **List rules**: list/create/delete, tenant-scoped. - **Outbound queue**: list, retry-now (verified: `next_attempt_at` genuinely rescheduled to immediate, not just a 200 response), cancel. - **Global quarantine**: list, discard (verified: status genuinely transitions to `deleted` in the DB). Per-user *release* (as opposed to discard) already existed in webmail from Phase 8 — this is the admin-wide review/cleanup view, not a duplicate of that. - **Dashboard**: user/domain counts, 24h message count, queue depth, quarantine-held count. **Two real bugs caught by testing, both fixed:** 1. `global_admin` has no *forced* tenant (unlike `tenant_admin`), but every domain still needs one — domain creation 400'd with "tenant_id is required" until a fallback to the admin's own tenant was added. 2. User creation requires a valid `domain_id` (a mailbox must belong to a domain) — neither the test nor the admin SPA's "Add User" form supplied one at first. Fixed in both: the SPA now has a domain ID field, and the Domains page shows each domain's ID (click to copy) so there's an actual way to get that value into the form. **Deferred within this phase:** - Aliases CRUD (table exists, no admin UI yet). - Pipeline settings UI (per-tenant score thresholds / check toggles) — the `tenants.settings_json` column exists but nothing reads or writes it yet; pipeline behavior is still entirely driven by the global `config.Pipeline` values. - TLS cert status view, DMARC/TLS-RPT report viewer — both depend on Phase 13 (ACME/DANE/MTA-STS) existing first. - Live log viewer (SSE) — webmail's `sseEvents` pattern from Phase 8 is directly reusable here, just not wired up yet. - Admin IP allowlist (`admin_ip_allowlist` in config) is not enforced at the HTTP layer — the admin server binds to `127.0.0.1` by default, which covers the common case, but the config value itself is currently inert. ### Phase 13 — TLS + ACME + DANE/MTA-STS — ✅ CORE COMPLETE (pulled forward) Pulled ahead of strict phase order per the reasoning that closing the `InsecureSkipVerify` gap improves the security posture of everything already built more than most net-new features would — see the handover doc for the fuller rationale. **What's done and verified:** - `internal/acme`: hand-rolled ACME v2 client (RFC 8555) — no third-party ACME/JOSE library. `jws.go` implements JWS signing (ES256, flattened JSON serialization per RFC 7515) and RFC 7638 JWK thumbprint computation from scratch. `client.go` implements the full protocol: directory fetch, nonce management, account registration, order creation, authorization polling, HTTP-01 challenge response, CSR building, finalize, and certificate download. `challenge.go` is the HTTP-01 responder (`/.well-known/acme-challenge/{token}`). `obtain.go` is the high-level orchestration function tying it together. - `internal/tlsutil/acme_manager.go`: `ACMEManager` provides SNI-aware certificate serving (`tls.Config.GetCertificate`), encrypted-at-rest storage (same HKDF-per-record scheme as messages/contacts/DKIM keys — new `tls_certs` table, migration 0010), in-memory caching, and a background renewal loop (checks daily by default, renews within 30 days of expiry). - Wired into `cmd/gomail/main.go`: when `tls.mode: acme` and `tls.acme_domains` are configured, a real `ACMEManager` drives cert serving instead of the self-signed fallback, and a plain `:80` listener serves the HTTP-01 challenge responder. Falls back to self-signed with a now-accurate warning message when ACME mode is set but no domains are configured, or when mode is `off`. - **Verified** with a from-scratch fake ACME server (`cmd/e2etest13`, deleted after passing per the established pattern) that does *real* protocol-level verification, not a rubber stamp: ES256 JWS signature verification against the account's actual public key, RFC 7638 thumbprint recomputation from that key, and a genuine HTTP callback to the challenge responder to check the key authorization matches — exactly what a real CA does. Confirmed: full obtain flow succeeds; the issued certificate works in an actual `tls.Dial` handshake with the correct `CommonName`; the certificate is encrypted at rest (checked the raw DB column, then decrypted and confirmed it's a real PEM cert); a second call hits the cache instead of re-issuing; a tampered JWS signature is correctly rejected; and — importantly — finalizing an order without completing domain validation is correctly refused with 403, proving the fake CA (and by extension, the real protocol logic our client drives) won't issue a certificate without genuine authorization. - Two stale/inaccurate log messages caught and fixed while wiring this in: the self-signed fallback's warning previously claimed "ACME is not yet implemented (Phase 13)" — no longer true, so the message is now context-aware (distinguishes "acme mode but no domains configured" from "mode is off entirely"). A comment in `accounts.IMAPProvider` about `InsecureSkipVerify` was similarly updated to reflect that the gap is now narrower (GoMail's own server can get a real cert; a *linked* external account's server is still outside our control regardless). **Deferred within this phase** (genuinely not started, not just untested): - DANE (TLSA record lookup + verification for outbound delivery) — not implemented. - MTA-STS (fetch/cache `.well-known/mta-sts.txt`, enforce TLS to declared MX hosts) — not implemented. - TLS-RPT (aggregate report generation/sending) — not implemented. - ACME DNS-01 challenge type (only HTTP-01 is implemented) — DNS-01 is necessary for wildcard certs, which GoMail doesn't currently need, but worth naming as a real gap rather than assuming HTTP-01 is sufficient forever. - No live test against a real CA (Let's Encrypt staging or production) — same category of limitation as Phase 10's OAuth2: no real public domain/DNS control in this sandbox to satisfy HTTP-01 validation from the actual internet. **The protocol implementation is proven correct against a genuinely-verifying fake server; a live run against Let's Encrypt staging is the natural first action for whoever continues this, same recommendation as Phase 10's OAuth caveat.** - Every `InsecureSkipVerify: true` in the codebase (linked IMAP accounts, the ACME manager's own... actually the ACME manager doesn't skip verification, only the *generic IMAP/OAuth2 linked account* paths in `internal/accounts` still do, since those connect to servers outside GoMail's control) is not yet an argument for removal — search the codebase for that string to find remaining call sites. ### Phase 12 — Auth hardening — ✅ CORE COMPLETE (TOTP + app passwords + reset) Completed after Phase 13 in this session's actual order (13 was pulled forward for its security-posture impact; this closed the gap in numeric order afterward). **What's done and verified:** - `internal/totp`: hand-rolled RFC 6238 TOTP (HOTP RFC 4226 underneath) — no third-party OTP library. **Verified against 5 of RFC 6238's own published Appendix B test vectors** (not just internal self-consistency): our 6-digit output exactly matches the last 6 digits of each published 8-digit reference vector, which is mathematically guaranteed to hold only if the HMAC-SHA1 counter derivation and truncation are byte-for-byte correct. Also verified: ±30s clock-skew tolerance works and correctly has a boundary (120s does NOT validate), and the `otpauth://` provisioning URI is well-formed. - Two-step login: `POST /api/auth/login` returns `{mfa_required: true, mfa_token: <5-min pre-auth token>}` instead of a session when `user.MFAEnabled` — `POST /api/auth/mfa-verify` redeems that token plus either a valid TOTP code or an unused backup code for a real session. The pre-auth token is a genuinely distinct, narrowly-scoped token type (`webtoken.Claims.Purpose = "mfa_pending"`), not just a normal session token handed out early. - MFA setup/confirm/disable: `POST /api/me/mfa/setup` generates a secret and stores it encrypted but **not yet enabled** — `POST /api/me/mfa/confirm` requires one valid code before MFA actually takes effect and backup codes are issued, so an abandoned setup never locks anyone out. 8 backup codes (SHA-256 hashed, one-time use, verified) are shown to the user exactly once, matching the app-password pattern. `POST /api/me/mfa/disable` re-requires the password (a session token alone isn't enough to turn MFA off). - App passwords: full self-service CRUD (`GET/POST /api/me/app-passwords`, `DELETE /api/me/app-passwords/{id}`) — the server-side scope-checking machinery already existed from Phase 5, this phase added the UI-facing API. Verified: the raw token is returned exactly once at creation and never re-appears in the listing response. - Password reset: `POST /api/auth/forgot-password` + `POST /api/auth/reset-password`, gated on a user-configured `recovery_email` (new `users.recovery_email` column) rather than the account's own mailbox — deliberately avoids the chicken-and-egg problem of emailing a reset link to a mailbox the person is locked out of. Verified: a genuine outbound-queue entry is created addressed to the recovery email (real delivery infrastructure, not a stub), and the endpoint returns the identical response whether or not the account exists (no account-existence leak via response differences). **Two real bugs found and fixed while testing, both worth remembering:** 1. `db.GetUser` was built in Phase 11 for admin's minimal display needs (id/email/role/active) and never extended when Phase 12 code started depending on it for MFA fields — `mfa_confirm` failed with "no pending MFA setup" even though setup had just succeeded, because the fetch silently dropped `totp_secret_enc`. Fixed by making `GetUser` the one canonical full-row fetch rather than fragmenting into admin-flavored/auth-flavored variants. 2. **A genuine SQLite connection-pool deadlock** in `ConsumeBackupCode`: it ran a `db.Query` (find the matching backup code) followed by a `db.Exec` (mark it used) in the same function, but this database is capped to a single open connection (`SetMaxOpenConns(1)`, see `internal/db/db.go`) — calling `Exec` while the `Query`'s `rows` was still open (only deferred-closed, not yet executed) caused the request to hang forever waiting for a connection that could only free up *after* the function returned. Fixed by closing `rows` explicitly before the `Exec`. **The codebase was then scanned for the same pattern** (any function mixing `Query` and `Exec`) and this was confirmed isolated — worth doing that scan again after any future change that mixes both in one function, given SQLite's single-connection constraint makes this an easy trap to fall into. **Deferred within this phase:** - **Passkey/WebAuthn — not started at all**, and deliberately so: it requires CBOR decoding for attestation/assertion objects, which is not in Go's standard library and would be a meaningfully sized sub-project of its own (on the order of the JWS/ACME work in Phase 13), with little shared surface with anything else in this codebase. `users. passkey_credentials_json` exists in the schema as a placeholder for when this is picked up. - QR code rendering for TOTP setup is not implemented — `totp. ProvisioningURI` returns the `otpauth://` URI as plain text, which every mainstream authenticator app accepts as manual entry; actual QR rendering (Reed-Solomon error correction, matrix placement) is real standalone work, noted in `totp.go`'s doc comment rather than silently skipped. - No webmail SPA pages for any of this yet — MFA setup/confirm, app password management, and recovery-email configuration are all functional, tested REST endpoints with no frontend UI. The existing `internal/webmail/static/index.html` pattern is directly reusable, just not built out for these yet. ### Phase 14 — Optional external services — ✅ CORE COMPLETE (ClamAV/Rspamd/LLM) **What's done and verified:** - `internal/pipeline/stage_clamav.go`: `ClamAVStage` speaks clamd's real documented INSTREAM binary protocol by hand — no third-party clamd client library. Send `"zINSTREAM\0"`, then the message in 4-byte- big-endian-length-prefixed chunks terminated by a zero-length chunk, then read the single-line response. Accepts `unix:` or `tcp:` prefixed addresses. A detected match is always a hard block (score 100) rather than a scored contribution, since malware detection isn't a "maybe." **Verified with a genuine EICAR test-string detection** — the fake clamd test server actually reassembles the streamed chunks and inspects the bytes for the real industry-standard EICAR signature, the same way real clamd does, rather than returning a canned response; a clean message correctly passes, and an unreachable clamd is handled as `CheckError` rather than crashing or hanging. - `internal/pipeline/stage_rspamd.go`: `RspamdStage` POSTs the raw RFC 5322 message to rspamd's real documented `/checkv2` HTTP endpoint and maps its JSON response (`score`, `action`, `symbols`) onto this pipeline's result model — `reject` → `CheckFail`, `add header`/ `rewrite subject`/`greylist` → `CheckWarn`, anything else → `CheckPass`. rspamd's own score is passed through directly rather than being rescaled, since both scales are already meant to be compared against configurable thresholds the same way. Verified: score pass-through is exact, action-to-result mapping is correct for all three cases tested. - `internal/pipeline/stage_llm.go`: `LLMStage` calls the OpenAI-compatible `/v1/chat/completions` endpoint that llama.cpp's server (and most other local-inference servers) expose — no llama.cpp-specific protocol needed. The model is asked for a single 0-100 integer; parsing extracts the first run of digits rather than requiring an exact match, since local models don't always follow format instructions exactly (verified with a deliberately messy response — extra whitespace and trailing commentary — that still parses correctly). LLM output is capped at 30 points of total score contribution regardless of what the model returns, since non-deterministic model output shouldn't singlehandedly quarantine mail the way a deterministic SPF/DKIM/DMARC failure can — verified the cap applies correctly (raw score 95/100 → capped contribution 28.5, not 95). - `pipeline.StagesFromConfig`: builds `DefaultStages()` (the always-on deterministic set) plus any of the three optional stages whose config field (`clamav_socket` / `rspamd_url` / `llm_url`) is non-empty — an unconfigured optional service is genuinely **absent** from the pipeline, not merely present-but-disabled, so a misconfigured or unreachable service that was never meant to be used can't accidentally affect delivery. Verified: 5 stages with nothing configured, 8 with all three configured. Wired into `cmd/gomail/main.go` in place of the old `DefaultStages()` call. **One real bug found and fixed while testing — in the test's fake clamd server, not the product code**: the fake server's initial read of the `"zINSTREAM\0"` command used a generic buffered `conn.Read()` call with a 32-byte buffer, which could over-read into the *next* protocol bytes (TCP doesn't preserve write-call boundaries, so a client's separate `Write()` calls can arrive coalesced in one read). That silently desynced the fake server's chunk-length parsing loop, which then hung waiting for bytes that had already been consumed — manifesting as an i/o timeout on the *client* side, initially indistinguishable from a real product bug. Fixed by switching the fake server to a `bufio.Reader` with exact-length `io.ReadFull` reads for both the command and every subsequent chunk. Worth remembering as a general pattern for any future protocol work over raw TCP: never assume one `Read()` call aligns with the sender's `Write()` call boundaries. **Deferred within this phase:** - Gmail API push (watch+Pub/Sub) and Microsoft Graph delta webhooks, to replace Phase 10's IMAP polling for linked accounts — not started. - No live test against real ClamAV, rspamd, or a real llama.cpp server — same category of limitation as Phase 10's OAuth2 and Phase 13's ACME: the protocol implementations are proven correct against genuinely- behaving fake servers, but never against the real services. If the operator has any of these installed, pointing `clamav_socket` / `rspamd_url` / `llm_url` at them and sending one real test message (the EICAR string is safe and appropriate for this) is the natural first live-test. - No admin UI toggle for these — they're config-file only (`pipeline.clamav_socket`, `pipeline.rspamd_url`, `pipeline.llm_url`/ `llm_model` in `config.yaml`), consistent with how they were already scaffolded as plain fields back in Phase 1. ### Phase 15 — Hardening + deploy — ✅ CORE COMPLETE (last phase in the plan) **What's done and verified:** - `internal/ratelimit`: hand-rolled per-key token-bucket limiter (no third-party rate-limiting library) — a token bucket rather than a fixed window deliberately, to avoid the classic "burst allowed at both edges of a reset boundary" flaw a naive counter has. `rate=0` cleanly disables limiting for a given listener rather than blocking everything, which is how an unset config value opts out. **Verified under Go's race detector** with 500 concurrent goroutines against a rate=100 limiter — exactly 100 allowed, zero drift, proving the mutex genuinely serializes access rather than merely looking correct single-threaded. Then proven over real sockets and real HTTP: a real TCP listener genuinely rejecting connections past the limit, a real `httptest` server returning genuine 429s past the limit, and a specific check that `X-Forwarded-For` trust correctly keys the limiter on the forwarded IP rather than the raw socket peer (only trusted when `server.real_ip_header` is explicitly configured — untrusted otherwise, since blindly trusting it would let any client spoof their rate-limit identity). - Wired into every listener: SMTP (per-IP connection limit at accept time, plus a separate limiter on AUTH attempts specifically, checked before any credential parsing happens), IMAP (per-IP connection limit at accept time), and a single **shared** limiter across DAV, webmail, admin, and external JMAP — deliberately one combined per-IP budget across all of them, not one each, since a client hitting its limit on one HTTP surface shouldn't get a fresh budget by switching to another. - Fuzz tests (`go test -fuzz`, real `*_fuzz_test.go` files — the first standard Go test files in this repo; everything before this used disposable `cmd/e2etestN` directories) on the four hand-written parsers most exposed to untrusted network input: `vcard.Parse`, `ical.Parse`, `sieve.Parse` (explicitly flagged as the highest-stakes target, since every ManageSieve `PUTSCRIPT` runs through it), and IMAP's `tokenize` (runs on every line a connected client sends, before authentication necessarily succeeds). Each actually run for 15s, not just written: ~400K–590K executions per target, ~1.5M executions total, zero panics. - `go vet ./...` — clean across the entire codebase. **`staticcheck` was attempted but is not runnable in this sandbox** — it requires a newer Go toolchain than this network-restricted environment can fetch (same class of limitation documented elsewhere in this plan for the real Go 1.25.6 toolchain). This is a real, disclosed gap: `go vet` provides real but narrower coverage than `staticcheck` would. Running `staticcheck ./...` on a real machine with normal internet access is a legitimate quick win for whoever continues this. - `install.sh`: creates a dedicated system user (no login shell), sets up `/etc/gomail`, `/var/lib/gomail`, `/var/log/gomail` with correct ownership, builds and installs the binary, generates `GOMAIL_MASTER_KEY`/`GOMAIL_JWT_SECRET` into a mode-600 env file if none exist, installs and enables (but does not start) the systemd unit. Syntax-checked with `bash -n` (clean); `shellcheck` isn't available in this sandbox either, so deeper static analysis of the script itself is another real machine follow-up. - `gomail.service`: systemd unit with genuine hardening — capability-based privileged-port binding (`AmbientCapabilities=CAP_NET_BIND_SERVICE`) instead of running as root, plus `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`, kernel/clock/hostname protection, namespace/SUID/realtime restrictions, `MemoryDenyWriteExecute` (with an inline comment flagging it as the first thing to try removing if the CGO-based binary fails to start under it — `mattn/go-sqlite3` requires CGO), and a syscall filter. **Actually verified with `systemd-analyze verify`** (available in this sandbox, unlike `staticcheck`/`shellcheck`) — returned clean against a dummy executable standing in for the real binary. - `README.md`: quick start, TLS/ACME guidance (including the staging-before-production reminder from Phase 13's own caveat), and a full DNS setup section — MX, SPF, DKIM (with the exact record shape GoMail logs at bootstrap), DMARC, all with real example values and guidance on starting permissive (`~all`, `p=quarantine`) before tightening. MTA-STS/TLS-RPT sections are present but honestly marked not-yet-implemented rather than filled with placeholder content. **Deferred within this phase:** - `staticcheck` and `shellcheck` — both blocked by sandbox network/ toolchain restrictions, not by any decision; run both on a real machine. - No fuzz corpus regression files exist yet (expected — Go only persists a corpus entry to `testdata/fuzz/` on an actual crash, and none of the 15s runs found one; longer fuzzing runs on a real machine, especially with `-fuzztime` measured in hours rather than seconds, stand a better chance of finding something a 15-second burst didn't). - No fuzz targets for the SMTP command parser or JMAP JSON handling specifically — the four targets chosen were judged highest-value for the time available; SMTP's command parsing is somewhat covered indirectly by the SMTP E2E tests from earlier phases but not fuzzed directly, and JMAP's JSON handling rides on `encoding/json`, which is already extensively fuzzed upstream in the Go project itself. - No log rotation configuration, no monitoring/metrics endpoint, no backup tooling for the SQLite database or Maildir tree — genuinely unstarted, not scoped in the original Phase 15 description either. --- ## Project status: all 15 phases from the original plan have now been ## touched, 13 of them completed and verified Phase 10 (native Gmail/Graph API, webmail account-switcher UI) and Phase 11 (aliases CRUD, per-tenant pipeline settings UI) remain "partially" or "core" complete with named remaining pieces — see their entries above. Every other phase reached a genuinely complete, tested state. This does **not** mean the project is finished in an absolute sense — every phase's entry above lists real deferred items, and the live-service verification caveats (OAuth2 against real Google, ACME against real Let's Encrypt staging) are still open — but the plan as originally scoped has been worked through in full, not just partially sampled. --- ## Architecture notes that matter for continuation ### Dependency management in a network-restricted sandbox The Anthropic sandbox this was built in blocks `proxy.golang.org` and `sum.golang.org` — only `github.com` (plus npm/pypi, irrelevant here) is reachable. Every session that needed a new dependency had to: ```bash git clone --depth=1 --branch vX.Y.Z https://github.com/OWNER/REPO.git /tmp/REPO ``` then add a `replace` directive in `go.mod` pointing at `/tmp/REPO`. **This does not apply on a real machine with normal internet access** — `go.mod` ships with real version requires and no replace directives; `go mod tidy` will just work. The `go.mod.production` file in each phase's tarball is already the clean version with no local paths. ### Go version Sandbox only had Go 1.22/1.23 available via `apt`; the user's real target is **1.25.6**. Every phase's shippable `go.mod` declares `go 1.25.6`; all sandbox development/testing happened against 1.22 syntax (no version-specific features used, so this is a non-issue functionally) with the directive temporarily lowered for local builds, then restored before packaging. **On the real 1.25.6 toolchain, nothing needs to change.** ### Sandbox environment quirks discovered (informational, not GoMail bugs) - The sandbox's default shell is `/bin/sh`, not bash — brace expansion (`mkdir -p {a,b}`) silently creates a literal directory named `{a,b}` instead of two directories. Always use explicit `bash -c` or separate `mkdir` calls. - The sandbox container has reset entirely mid-session at least once (Go toolchain, `/tmp`, and the whole working directory vanished) and resets `/etc/hosts` between *every* tool call. Backgrounded long-running processes (`&` + later `kill`) don't reliably survive across tool call boundaries either. **The working pattern that survived all of this**: write E2E tests as a single self-contained `go run` invocation that starts the server in-process (as a goroutine) and exercises it as a client in the same process — never rely on a detached background process still being alive in a later tool call. - Because of the above, **always keep a tarball of the last-known-good state in `/mnt/user-data/outputs`** — that survived every reset and was the actual recovery mechanism used once. ### Testing pattern used throughout Every phase has (had, before cleanup) a `cmd/e2etestN/main.go` that: 1. Boots the real server(s) for that phase as goroutines in the same process 2. Acts as a real client over the real wire protocol (raw TCP for SMTP/IMAP/POP3, real `net/http` for DAV/webmail/JMAP) 3. Asserts against actual database state, not just protocol response codes 4. Is deleted after the phase's tests pass — **not shipped** in the tarballs, since it's sandbox-only scaffolding, not part of the product **When continuing this project, recreate this pattern per phase** — it caught a genuine bug in nearly every phase (see "Bugs actually found" below) that a build-only check would have missed entirely. ### Bugs actually found by this testing pattern (worth remembering the *pattern*, not the specific fixes) - NULL-scan panics from `sql.NullString`-shaped columns not being wrapped correctly (Phase 2, Phase 3) - A whole SMTP port (`:465` implicit TLS) silently not receiving the outbound-relay code path because a check only looked for one of two equivalent session kinds (Phase 2/3) - Foreign-key ordering: child rows inserted before their parent row existed (Phase 4) - Regex `.` not matching embedded `\r\n` from IMAP literals — an entire class of FETCH responses silently parsed as empty (Phase 6) - Wrong wire command entirely (`FETCH UID 1` sent instead of `UID FETCH 1`) — server correctly returned nothing, client incorrectly assumed "not found" (Phase 6) - Missing `json` struct tags meant to the wire API would have shipped `TotalCount` instead of `total_count` (Phase 8) — caught before it ever ran, not after **Takeaway for whoever continues this: don't trust "it compiles" or even "the happy-path test passed once." Write the negative-path tests (wrong auth, cross-user access, malformed input) every time — they found real bugs in every single phase.** --- ## File map (as of end of Phase 15 — the full plan) ``` gomail/ ├── cmd/gomail/main.go # wires everything together ├── go.mod / go.mod.production # sandbox-dev vs real-deploy versions ├── install.sh # system user, directories, secrets, systemd install ├── gomail.service # hardened systemd unit — verified with systemd-analyze ├── README.md # quick start, TLS/ACME guidance, DNS setup guide ├── internal/ │ ├── accounts/ # MailProvider interface + local/IMAP backends │ ├── auth/ # shared password + app-password verification │ ├── config/ # YAML + env config loader │ ├── crypto/ # HKDF-per-record AES-256-GCM encryption │ ├── db/ # schema, migrations, all queries │ ├── dav/ # CalDAV/CardDAV HTTP server │ ├── dkim/ # DKIM sign + verify (stdlib crypto) │ ├── ical/ # minimal RFC 5545 parser/builder │ ├── imap/ # IMAP server (core command set) │ ├── imapclient/ # hand-rolled IMAP client │ ├── jmap/ # JMAP Core/Mail subset │ ├── acme/ # ACME v2 client (RFC 8555) — JWS signing, full protocol flow │ ├── admin/ # admin portal REST API + embedded dark Tailwind SPA │ ├── mailstore/ # encrypted Maildir storage │ ├── managesieve/ # RFC 5804 ManageSieve server │ ├── oauth2/ # hand-rolled OAuth2 client (auth code grant + refresh) │ ├── pipeline/ # SPF/DKIM/DMARC/header/URL security checks │ ├── pop3/ # POP3 server (off by default) │ ├── ratelimit/ # per-key token-bucket rate limiter (SMTP/IMAP/HTTP) │ ├── sieve/ # RFC 5228 Sieve interpreter (lexer/parser/exec) │ ├── queue/ # outbound retry queue + MX delivery │ ├── smtp/ # SMTP server (inbound MTA + submission) │ ├── tlsutil/ # self-signed cert generation + ACMEManager (SNI-aware serving, renewal) │ ├── totp/ # RFC 6238 TOTP MFA — verified against real published test vectors │ ├── vcard/ # minimal RFC 6350 parser/builder │ ├── webmail/ # REST API + embedded Tailwind SPA │ └── webtoken/ # hand-rolled JWT (HS256, plus scoped Purpose tokens for MFA/reset) ``` Native Gmail/Graph API providers (as opposed to the IMAP+OAuth2 path already built) are not yet in `internal/accounts` — see Phase 10's deferred-items list above. `internal/dane`, `internal/mtasts`, and `internal/tlsrpt` don't exist yet — see Phase 13's deferred-items list. No `internal/webauthn` (passkeys) — see Phase 12's deferred-items list; `internal/totp` exists and is fully wired. That's the complete list of genuinely unstarted packages as of the end of Phase 15 — every other phase in the original plan has at least a core-complete, tested implementation.