cosmix-maild — JMAP-native mail daemon

cosmix-maild is a single-binary mail server: SMTP in and out, IMAP and JMAP for clients, CalDAV/CardDAV for calendars and contacts, and a three-stage inbound filter that verifies, rules-checks, and Bayesian-classifies every message. It is a first-class Bus citizen — everything an operator would reach for a CLI or config reload to do is also an addressable Bus verb.

What it is

One daemon that speaks the standard mail protocols on their standard ports, stores everything in a content-addressable metadata store, and exposes its whole management surface over Bus. JMAP is the native model; IMAP and SMTP are mapped onto the same store. It is built from a small family of crates:

CrateRole
cosmix-maildthe daemon — protocol servers, filter pipeline, Bus surface
cosmix-mdsmail data store — per-container SQLite metadata + content-addressable blob CAS
cosmix-maild-authSPF / DKIM / DMARC / ARC / iprev verification and DKIM/ARC signing
cosmix-maild-rulesdeterministic Sieve-style rule engine (stage two of the filter)
cosmix-maild-bayesianRobinson-Fisher spam classifier, per-account (stage three)
cosmix-maila headless JMAP client app (an Bus citizen that reads/composes via a maild)

What it does

  • Inbound SMTP on port 25 with opportunistic (or required) STARTTLS. Each message runs the three-stage DATA filter: cosmix-maild-auth verifies SPF / DKIM / DMARC / ARC / iprev and prepends an Authentication-Results header → cosmix-maild-rules returns HardAccept, HardJunk, or Continue → cosmix-maild-bayesian scores anything that continued and routes it to Inbox or Junk.
  • Delivery log. Each classified recipient logs one line, Spam classification: SPAM (score 0.00, source=rules:mail_auth_hard_fail) for <rcpt>, with source also emitted as a structured field. source=bayes means the rules engine continued and the Bayesian score decided. source=rules:<why> means the rules engine short-circuited. A single deciding signal is named by its reason (allowlist_sender, blocklist_sender, mail_auth_hard_fail, structural_anomaly). A score breach names the rules whose weights summed past hard_junk_threshold, joined with +. The score shown for a rules verdict is the rules score, not a Bayesian probability.
  • Training log. Each single-message training event logs one line under the tracing target maild::bayesian::train. This covers an IMAP move across Junk (drained by the retrain worker), a JMAP move, and maild.bayesian.train. maild.bayesian.rebuild does not log per message. It trains a shadow corpus in bulk and reports its counts in the job body instead. The line carries account, direction (spam / ham), stamp (the item id), message_id, result (applied, already_labeled, or error) and via (imap, jmap, bus). maild.bayesian.untrain logs a different line under the same target, with account, direction=untrain, stamp, removed (spam, ham, or none when the message carried no label) and via=bus; it has no message_id or result. An inline label (a JMAP move, train or untrain) takes a microsecond event instant when it starts. Once its write succeeds, it cancels the IMAP moves of the same message that were queued before that instant and logs result=superseded with the count. A move queued after the instant is a newer user action; it is kept and drains after the inline label. If the inline write fails, nothing is cancelled. Queued rows carry the instant in mail_retrain_outbox.created_us (MDS schema v10); rows from before v10 count as older. The drain skips a row cancelled after it was claimed, so an older queued event is never replayed over a newer label. maild.bayesian.stats reports labelled_spam, labelled_ham and last_trained_at, so you can confirm that a correction was learned without comparing corpus totals.
  • Submission on SMTPS (465), signing outbound mail with DKIM and sealing forwards with ARC.
  • Client access via IMAPS (993) and JMAP over HTTP, plus CalDAV/CardDAV for calendars and contacts — all backed by the same cosmix-mds store.
  • Spam training and classification that live and update per account.
  • vtoken — an address-as-RPC scheme where a minted token encodes a scoped capability into an email address (mint / lookup / disable / list over Bus).
  • Retention — a background worker that ages messages out per policy.
  • DKIM key lifecycle — generate, rotate, and retire signing keys per domain.

Running it

--version or -V anywhere in the arguments (including after serve) prints the compiled version and exits before TLS setup, configuration loading, database access or listener binding.

/opt/cosmix/bin/cosmix-maild --config /etc/cosmix/maild/config.toml serve

Subcommands cover setup and operations: migrate (run DB migrations), account (account management), queue (SMTP queue), plus per-account rule overrides. Run under systemd as cosmix-maild.service, ordered After=cosmix-noded.service so the broker exists first. Listen addresses default from /etc/cosmix/node.toml (JMAP, SMTP, SMTPS, IMAPS) and are overridable in the per-daemon config.toml, which also carries the [[dkim.domain]] signing config. Each listen field accepts a single address or a list, for multi-homed binds.

Interfaces

SMTP, SMTPS and IMAPS normalise IPv4-mapped IPv6 peers to plain IPv4 at accept time, so reverse DNS, SPF/iprev, rate limiting and logging use the same peer address.

  • Protocol ports: SMTP 25, SMTPS submission 465, IMAPS 993, and JMAP over HTTP (dev default 127.0.0.1:8088); CalDAV/CardDAV share the HTTP surface.
  • Bus management surface — the operational verbs, grouped:
  • submission: maild.submit
  • accounts: maild.accounts.seed_mailboxes, maild.accounts.seed_content, maild.accounts.revoke_tokens
  • stats: maild.stats.server, maild.stats.account, maild.stats.mailboxes, maild.stats.online, maild.stats.top
  • rules: maild.rules.reload, maild.rules.explain, maild.rules.stats
  • bayesian: maild.bayesian.classify, maild.bayesian.stats, maild.bayesian.train, maild.bayesian.untrain, maild.bayesian.rebuild, maild.bayesian.rebuild_status
  • dkim: maild.dkim.generate, maild.dkim.rotate, maild.dkim.retire
  • vtoken: maild.vtoken.mint_opaque, maild.vtoken.lookup_opaque, maild.vtoken.list_opaque, maild.vtoken.disable_opaque
  • retention: maild.retention.run, maild.retention.status
  • search / tls: maild.search.rebuild, maild.tls.reload
  • Property surface: the SPEC 12 maild.props.* namespace (list / set / delete / watch), with change events on the maild.props.records.changed topic.

Native plain-text submission

maild.submit accepts a JSON object with operation_id, account, to (an array of mailbox addresses), subject and text; optional from defaults to the named account. The account must exist and be unlocked. A different sender must be an enabled alias authorised for that account. The verb uses the same native Bus administrative access as the other management verbs.

The operation ID is 1–128 ASCII letters, digits, hyphens, underscores or dots. There must be 1–32 distinct recipients. The subject is limited to 4096 bytes and cannot contain line breaks; text is limited to 256 KiB. Unknown fields, raw messages, headers and attachments are rejected. The rendered message must also fit the configured server message limit.

Submission creates a CAS-backed email and uses the existing local delivery, outbound queue and DKIM signing pipeline. A successful receipt reports status: "accepted", the operation, submission, email and message IDs, recipient count, and delivery_confirmed: false. Acceptance does not prove remote delivery.

The durable key is the canonical submitting account plus operation ID; the original Bus actor is retained for audit. Repeating the same typed payload returns the saved receipt without another delivery or queue entry; reusing that key with another payload is rejected. A reservation without a saved receipt means the outcome is uncertain, including after a process crash. It is retained and never automatically resent. Do not use a new operation ID to retry an uncertain send without first establishing whether it was delivered.

Bayesian corpus rebuild

maild.bayesian.rebuild accepts account_id (an integer or digit string), snapshot (optional, default true), wait (optional, default false), and allow_empty (optional, default false). It reserves one in-memory job per canonical account ID, enumerates the current mail store, and trains a per-job <account>/bayes.rebuild-<pid>-<unix_ms>.db without touching the live corpus. An exclusive non-blocking advisory lock on <account>/rebuild.lock serialises rebuilds across maild processes; stale per-job shadows are removed only while that lock is held. Junk is Spam; Trash, Drafts, Sent, and upload staging are ignored. A mailbox inherits the nearest ancestor's special-use role when it has none of its own; otherwise it is Ham. User corrections made while enumeration or training runs are replayed into the shadow corpus before it is validated and atomically copied over the live corpus in one SQLite write transaction.

Mailbox enumeration is not one MDS snapshot. The fence and replay protect user corrections: moves across the Junk boundary, which write a live label row. The swap re-reads that correction set inside the same BEGIN IMMEDIATE transaction used to replace the corpus. If the set changed, maild re-derives those items from their current folder memberships, replays and revalidates the shadow, then retries. Five consecutive conflicts fail the job. Membership-only changes after enumeration — a deleted message, a move between non-Junk folders, or newly delivered mail — are not corrections, write no label row, and are reflected by the next rebuild. The corpus is folder state as of enumeration, plus every correction made before the swap.

After the initial shadow validation and before the first replay read, snapshot: true writes a consistent bayes.pre-rebuild-*.db rollback copy. A refused empty rebuild creates no snapshot. Retention pruning runs only after a successful swap, keeping the newest two snapshots. Aborted or failed jobs delete the shadow database and leave the live corpus unchanged.

Before snapshot creation, after replay, and after each conflict retry, a zero-message shadow fails with rebuild produced an empty corpus; live corpus left untouched (pass allow_empty: true to replace it anyway). Set allow_empty: true only when deliberately clearing the live Bayesian corpus.

bayesian_rebuild_operators in maild's conf.mix is an opt-in peer allowlist for maild.bayesian.rebuild. Its semantics are deliberately the inverse of retention_operators: EMPTY means open to every Bus peer, the deliberate agentic-first default; NON-EMPTY admits only exact cmd.from matches. rebuild_status, stats, and classify remain open regardless of this list.

The job body contains state, started_at, finished_at, candidate and trained Ham/Spam counts, replayed, already_labeled, skipped_missing, conflicts, errors, last_error, snapshot, and ignored_mailboxes. wait: true holds maild's Bus dispatcher for the whole rebuild and is subject to the client's 60-second request timeout; use it only for tests or small mailboxes. Real mailboxes should use wait: false, which returns the reserved running job immediately, then poll maild.bayesian.rebuild_status with the same account_id. Status returns the latest daemon-memory job, or idle when none has run since startup.

Where it fits

  • Registers with and is addressed through noded, the Bus broker.
  • Links the Bus protocol family (cosmix-lib-bus, cosmix-lib-client) from the bus repo, and substrate libraries cosmix-lib-config, cosmix-lib-daemon (TLS / ACME), cosmix-lib-props-store, and cosmix-lib-log.
  • Parses its native *.conf.mix config through cosmix-lib-mix from the mix repo.
  • cosmix-webd fronts webmail against maild's JMAP; cosmix-mail is a native client; both drive it over JMAP + Bus.

See also

  • noded — the Bus broker maild registers with
  • libraries — the substrate crates maild links
  • overview — the cos daemon family
  • bus messaging — the Bus primitives used to drive maild's management verbs