first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+672
View File
@@ -0,0 +1,672 @@
# GoMail — Action Plan v4 (post-Phase 9)
**Status: Phases 19 complete and verified. Phases 1015 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 19)
| 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:
~400K590K 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.