mail
A mail server with webmail, for an instance's own domains and its teams: mailboxes, aliases, catch-all,
dev inboxes, and the outgoing mail of the platform itself. Host: mail.<d> (webmail and admin),
mx.<d> (SMTP / IMAP). Binary: maild. Stage 10, on its own node and IP (ADR 0009).
As built (stage 10)
maild runs on the control plane (ADR 0016): the panel and API on 8716 behind Caddy (mail.<d>), mail's
ports on 10025, 5587, 4465, 1143 and 1993. An edge server (xvii-node edge <control plane IP>) owns
mx.<d>: it relays 25, 587, 465, 143, 993 (and 2465, TLS submission again for servers whose provider
blocks outgoing 465 and 587) with a PROXY protocol line (trusted from TRUSTED_PROXIES
only) and lets maild send from its address through a SOCKS5 egress (OUTBOUND_PROXY). The hostname's
certificate comes by DNS-01 through Cloudflare (CLOUDFLARE_TOKEN); with no token, HTTP-01 when the
hostname reaches this server's Caddy.
- Domains belong to a team (admins add them; the instance's domain and its subdomains only from
superusers). Each gets a 2048-bit DKIM key (sealed), and a checklist — MX, SPF
v=spf1 mx -all, DKIM,
DMARC p=quarantine, PTR of the node's IP — checked live and hourly, and offered as a zone file. A
domain is ours only while its MX points here: until then nothing is delivered locally for it and nobody
sends as it. New DKIM key = a new selector. When the domain's zone is in xvii domains under the same
team, mail writes the records there itself (on adding the domain, on a new DKIM key, or by Set up in
xvii domains) through PUT /api/v1/mail-records/{name} with its service token (journal
maildomain.dns here, zone.records there).
- Extras, not needed for mail to work: SRV records for IMAP and SMTP (RFC 6186), TLS-RPT reports to the
instance's postmaster, and with xvii domains the names
autoconfig., autodiscover. and mta-sts.
of the domain pointed at the control plane (domains attaches them to mail and relays them): Thunderbird's
autoconfig file, Outlook's Autodiscover answer, and an MTA-STS policy (enforce, the hostname as MX).
A new app password can be downloaded as an Apple profile (iPhone, iPad, Mac), made in the browser.
- Mailboxes (address, display name, quota, owner): the owner reads it; a shared one (no owner) the team's
developers and up; admins manage but don't read others' mail. IMAP and SMTP clients sign in with app
passwords only (
xvm_…, shown once, sha256 at rest, last use kept); ten failures from an address in 15
minutes lock it out for the rest.
- Aliases deliver to mailboxes here and forward elsewhere (forwards leave from the alias's address);
name+tag@ reaches name@; a catch-all per domain. Mailboxes send as their address and their team's
aliases that deliver to them. Dev inboxes: <team>.inbox.<d>, any address, every member reads it,
receive only.
- Inbound: DNSBL (
zen.spamhaus.org by default), greylisting by /24 + sender + recipient (2 minutes;
senders whose SPF passes skip it, as big providers retry late and from other networks),
SPF, DKIM and DMARC with relaxed alignment into Authentication-Results; DMARC reject failures are
refused, quarantine and hard SPF failures go to Junk; quotas checked at RCPT.
- Outbound: one queue entry per recipient, DKIM-signed at enqueue; MX by preference, opportunistic
STARTTLS (verified for a smarthost); retries after 1, 5, 15, 30 min, 1, 2, 4, 8 h, then every 12 h for
five days; a 5xx bounces at once: a DSN (multipart/report) into the sender's inbox. 500 messages a day
per mailbox by default. The queue is visible to the team's admins only.
- Storage: metadata, flags, threads (In-Reply-To/References) and a
tsvector in PostgreSQL; bodies on
disk by sha256 (deduplicated, pruned hourly when unused).
- IMAP4rev2 (go-imap v2): IDLE, MOVE, UIDPLUS, SPECIAL-USE, LIST-STATUS, ESEARCH; sessions, the webmail
and deliveries see each other's changes at once.
- Webmail: a REST API (
/api/v1/mail/<mailbox>/…: folders, threads, messages, flags, move, delete,
send with attachments, drafts, SSE events) instead of JMAP for now; HTML mail in a sandboxed frame
without scripts, remote images on request. Admin: domains, mailboxes and app passwords, aliases,
outgoing queue, journal (maildomain.*, mailbox.*, apppassword.*, devinbox.create,
mailalias.*, queue.retry). Limits mail.domains, mail.mailboxes, mail.storage.
- Platform mail: services on the control plane submit over loopback (
127.0.0.1:10587, no TLS, a
mailbox's app password): notify's SMTP_URL=smtp://no-reply@<d>:<app password>@127.0.0.1:10587.
- Later: JMAP, a Bayesian filter, additional MTA-STS/TLS-RPT/DANE work, ARC, DMARC reports,
per-domain concurrency, S3 bodies, POP3, calendars and contacts. Rules, signatures and identities
ship in Mail 2.0.0 below.
Webmail 2.0
Mail 2.0.0, released on 2026-10-18, adds the webmail functionality and reliability package below.
The release integrates the existing build-brand support and team-scoped directory authorization fix.
The stage-10 description above remains historical where the behavior is superseded here. JMAP, POP3
and further antispam-server development remain separate follow-ups. Instance-specific deployment and
backup results belong in the operator's private journal, not this public service specification.
- Drafts/uploads: stable draft IDs and revisions; conditional creation and replacement, 428 for
missing conditions and 412 for stale revisions. Replacing MIME creates a new IMAP UID, never changes
an existing UID's bytes. Incomplete address buffers can save; sending validates them strictly.
Opaque binary uploads belong to one mailbox and reserve quota. The legacy base64 adapter links
inline files inside the MIME replacement transaction without a second staging charge, so a net-sized
replacement still fits at full quota; failed replacements leave no upload rows or lost original MIME.
Complete reopening preserves From, To/CC/BCC, reply metadata, body and files; bodies/files are not a
second JSONB content cache. Storage transactions lock team scopes in lexical order before mailboxes
and check current database usage against the existing cached plan limits and mailbox quota. This
covers MIME, staged uploads, held snapshots, local delivery and IMAP copies, not only HTTP prechecks.
Transaction-local usage baselines allow net-zero transfers after limits are lowered, without granting
new growth. An unavailable/unset effective limit retains the existing limits-client semantics.
- Send: a durable, immutable accepted snapshot and mailbox-scoped idempotency key. Identical
replay returns the original receipt; changed intent conflicts, and cancelled receipts never resend.
Panel sessions get a ten-second hold or a UTC schedule 1 minute–90 days ahead. The composer rejects
nonexistent local times and requires an explicit UTC offset when clocks repeat an hour; it displays
the chosen timezone and offset. Tokens and SMTP retain immediate-send behavior. Cancellation and dispatch share locks; cancellation restores one
editable draft. Membership, role and send-as are refreshed at dispatch and fail closed. A blocked
item needs explicit retry; it is not silently rescheduled.
- Handoff: local copies, remote queue entries, Sent, Answered, storage transfer and rolling
recipient usage commit together. DKIM is prepared at dispatch; BCC and draft-private transport
headers are stripped. The default limit is 500 original envelope recipients over a rolling 24 hours,
including local recipients; retries do not charge again. Queue leases use fenced claims/results.
Incoming SMTP similarly commits all accepted local copies and alias forwards in one transaction;
a later quota or queue failure returns 451 with no partial recipient/accounting changes. Repeated
RCPT addresses do not duplicate a mailbox copy or the same alias/forward destination. Routing lookup
failures are temporary errors, never a missing-recipient or external-forwarding decision.
Migration
0007_routing tracks a shared database routing generation. Preparation captures it before
route/signing lookups; handoff validates and share-locks it until commit. Alias/domain/mailbox routing
or ownership changes invalidate stale preparation, including negative lookups. Configuration writes
wait for an active pinned handoff; ordinary message/quota accounting does not advance the generation.
An incoming change after RCPT yields a temporary DATA failure; scheduled sends prepare again after
backoff. The counter is global, so unrelated routing edits can conservatively defer another send.
This does not promise exactly-once delivery after ambiguous SMTP acceptance in either direction.
- Settings: revisioned authorized identities, display names, plain-text signatures, default sender,
templates and shortcut preference. Identity names are frozen into saved MIME; later settings do not
rewrite existing mail. No HTML signature/template assets or automatic recipient insertion.
- Search/bulk: explicit All/folder scope; All excludes Trash/Junk. Predicates match one message,
then complete scoped conversations are aggregated and paginated by opaque deterministic keysets.
Filters and initial maximum message ID bind the cursor. Bulk actions validate the complete scoped
member set/version first (100 conversations/1,000 messages) and commit all or nothing. Trash and
permanent deletion are distinct; permanent deletion requires the exact message count.
- Rules: at most 100 ordered rules, each with 1–10 bounded conditions and 1–5 actions, all/any and
stop. New rules are disabled. Sender/domain, To/CC and subject conditions support literal matching;
actions move or change read/star/spam flags. No bodies/BCC, regex, forwarding, scripting, webhooks
or permanent deletion. Only new received/local-recipient mail runs filing; APPEND, drafts, Sent,
moves and DSNs do not. Mandatory quarantine stays in Junk. Existing-mail preview is explicit and
bounded to 1,000 newest candidates; its ten-minute token binds exact message versions and ruleset.
MIME preparation runs outside locks and is revalidated before issuing the token. Apply is atomic,
single-use and rejects changed samples. A conflicted editor can save a new disabled copy.
- Panel: compact-only contiguous rows, expanded message contents, inline reader with actual-list
previous/next, guarded configurable shortcuts, selection-aware actions, safe composer navigation,
Undo/Scheduled views and persistent readable folders on admin routes. EN/RU and light/dark remain
supported; the shell uses compact search below 1280 px and truncates the phone team switcher.
Diagnostics and retention
Every minute, mail backlogs logs aggregate counts and oldest ages in seconds: pending, held,
blocked, overdue schedules, ready/leased/expired-lease/retrying queue jobs, orphan uploads and expired
rule previews. Failures use fixed warnings, not SQL/private payloads. Hourly cleanup removes expired
previews, unlinked uploads older than 24 hours and unused blobs. Pending/blocked snapshots remain
pinned. Finished submission private payloads expire after 30 days; idempotency receipts remain.
Audit/events/diagnostics must not include bodies, BCC, recipient lists, filenames, signatures,
templates, MIME or rule conditions. Delayed-mail content remains available only to mailbox readers,
not generic admin metadata endpoints.
Migration and rollout prerequisites
These prerequisites apply when upgrading from pre-2.0 workers.
- Re-run relevant integration checks for the release candidate, build the matching panel/binary and
inspect additive migrations
0002_drafts through 0007_routing. maild applies migrations before
starting workers.
- Stop all old maild processes/workers before migration. Do not overlap old unfenced workers with
fenced queue workers, or run an old binary against new submission/queue writes. Wait for active
SMTP attempts to finish where possible; an interrupted ambiguous acceptance can still duplicate.
- Take a matched database and data-directory backup, including blobs and
seal.key, while writes
are stopped. Treat it as sensitive. Test restoration; a database-only backup loses message content
and a blob-only backup loses lifecycle/lease/idempotency state.
- Start only the matching new version. Verify migration completion, directory authority refresh,
mailbox/IMAP access, backlog counts and cleanup. Use isolated local mail for checks, not arbitrary
real recipients. Pending due schedules may dispatch immediately after restart.
- There is currently no independent pause-dispatch configuration switch: to stop dispatch and
outbound safely, stop all maild instances (this also stops the API/SMTP/IMAP service). Pause before
investigation rather than editing lifecycle/lease tables by hand. Cancel/edit through the supported
mailbox API only when the matching service is running and cancellation still wins.
- Do not simply downgrade the binary after new writes. Prefer roll-forward. If rollback is necessary,
keep workers stopped and restore the matching database and data directory from the same backup;
account explicitly for mail accepted since that backup and ambiguous external deliveries. Never
drop new tables or replay receipts as a shortcut.
Verification and known limits
Core package/race tests and isolated real-handler browser scenarios have passed, including complete
CC/BCC/file draft recovery, Undo, schedule reload/cancel-edit, exact-set rules and selected-conversation
star shortcuts. Sixteen simultaneous conditional draft writers produce one acknowledged winner for
both creation and replacement, with one MIME message and correct quota. Legacy inline-file replacement
also succeeds at full quota when its net size fits and rolls back cleanly when it does not. A real open
loopback IMAP session sees the replacement's fresh UID, cannot fetch the old UID and can expunge the
new one without allowing a stale browser to resurrect it. Recipient-limit recovery requires explicit
retry, and stale preparation cannot emit a false blocked lifecycle audit/event. Sixteen simultaneous
cross-mailbox message/upload/draft writes cannot exceed one team's storage cap; reversed cross-team
mailbox orders complete without deadlock. Lowered team/mailbox limits still allow net-zero draft and
cancellation transfers. A storage-blocked dispatch rolls back all recipients/usage and requires explicit
retry after capacity recovers. Real-handler settings/composer checks preserve edited signatures,
confirm template replacement, leave recipients unchanged and reopen without duplicate signatures.
Shortcut opt-out survives reload; IME/repeat/modifier events, focused inputs and open dialogs do not
invoke shortcuts, while ordinary compose typing remains intact. Real-handler search/bulk/navigation
checks cover filter reload, All/Trash scope, actual-list boundaries, browser history, read/star/trash,
unselected conversations and cancelled/confirmed permanent deletion with the exact scoped count.
Reading refreshes list versions before the reader is ready, so returning and selecting immediately
does not submit the list's pre-Seen versions; genuinely stale selections still fail atomically.
Loopback SMTP checks cover post-RCPT quota/queue/routing failure, complete rollback and successful retry
with duplicate RCPT addresses. Routing tests cover changed aliases, mailbox addresses/ownership and
newly local domains, plus configuration writes blocked by an active pinned generation.
The list, advanced search, reader, settings/templates, composer and shortcut help passed the full
320/390/820/1024/1440 px × EN/RU × light/dark matrix. The rule list/editor was also checked at these
sizes/languages/themes. An actual New York browser rejects the spring-forward gap and sends the chosen
fall-back occurrence as the exact UTC instant; unit tests also cover Lord Howe's half-hour transition
and Kathmandu's fractional-hour offset. Server tests reject malformed, offset-free, past and out-of-range
schedules, replay equivalent UTC instants and conflict on a changed instant under the same key. Test
schedules were cancelled and no transport workers ran. Cleanup tests retain pending/blocked snapshots,
clear expired cancelled payloads without losing their receipts or restored draft files, then reclaim
unreferenced content and quota after the restored draft is discarded. Dispatched retention tests clear
expired private payloads while preserving the permanent receipt and live Sent/queue MIME. Replaying the
receipt cannot repeat the handoff; each blob is reclaimed only after its last Sent/queue reference is
removed, with no leaked mailbox quota.
This is not a claim that every screen/edge case or production load has been verified. Production-load
validation is not part of the isolated verification above; operators must verify matched backup
restoration during each rollout. The normal node installer can roll back only a binary, which is not
safe after new lifecycle writes: suppress that automatic downgrade for this migration, fail closed
and use the matched restore procedure if needed. Authority refresh cannot lock changes in the remote
directory atomically with the local transaction; mailbox ownership and routing are revalidated at
handoff. A disconnect after
committed SMTP acceptance remains ambiguous. Empty identity names currently fall back to the mailbox
display name. The interactive test fixture intentionally has no transport workers and no catalog
endpoint; its catalog 404 is not a clean-console claim.
Server
- SMTP: inbound on 25 (MX), submission on 587 (STARTTLS) and 465 (TLS) with auth; outbound with queues,
retries and backoff, per-domain concurrency, optional smarthost.
- Authentication of mail: DKIM signing (keys per domain, rotation), SPF, DKIM, DMARC and ARC checks on
inbound, DMARC reports, MTA-STS and TLS-RPT, DANE-aware delivery later.
- Spam and abuse: DNS block lists, greylisting, SPF/DKIM/DMARC results, rate limits per sender, a
Bayesian filter trained by the user's "spam / not spam", per-mailbox rules.
- Access: IMAP4rev2 (+ IDLE), JMAP (core, mail, submission; calendars and contacts later) for the
webmail, POP3 off by default.
- Libraries: emersion
go-smtp, go-imap/v2, go-msgauth, go-message (ADR 0009).
- Storage: metadata in PostgreSQL (mailboxes, threads, flags, full-text index with
tsvector), message
blobs on disk or S3, deduplicated by hash. Quotas per mailbox and per team.
Admin
Domains per team with a DNS checklist (MX, SPF, DKIM, DMARC, MTA-STS, rDNS of the node) verified live
and offered as a zone file; mailboxes and app passwords (for IMAP/SMTP clients); aliases, catch-all,
forwarding; dev inboxes *@inbox.<d> that accept anything and show it in the panel (for testing sign-up
flows); queue and delivery log; reputation checks of the node's IP.
Webmail
Our stack (React + shadcn, @xvii shell) over JMAP: threads, search, labels/folders, compose with
drafts, attachments, signatures, identities, filters, keyboard shortcuts, push through JMAP EventSource,
PWA. Calendars and contacts after mail is solid.
id sends sign-in links and invitations, notify sends e-mail notifications, status status page
subscriptions: all through maild when installed, otherwise the instance's SMTP relay.