All docs

Docs · Reference

IMAP

A mail client as the UI

The imap personality serves a durable mailbox store to any IMAP client — Apple Mail, Thunderbird, mutt. Stacks put messages into it with an op; the client reads them. The head is a generic IMAP server: it knows no folder names and no product.

Nothing lands in a mailbox on its own. Inbound mail is parsed onto the envelope and forgotten, exactly as before; a rule that wants a message to show up in a client calls txco://imap/append with the record it chose to keep — a headers subset, the normalized body, attachment references — and the head renders RFC 5322 from that record when a client fetches it. The wire form is derived; the record is what is retained.

The other direction is a stack hearing what the client did: every committed mutation (a drag into a folder, a new folder, a delete) can reach the tenant’s _imap stack after the fact, and a folder can be set to ask the stack first. That is how “any folder the owner drops a PDF into becomes knowledge” is written in txcl, not in Go.

Turn it on

txco serve --personalities cron,web,admin,imap \
  --imap-listen-addrs :1143            # plaintext; or
  --imap-tls-addrs :993 --imap-hostname imap.example.com

Both gates must be flipped: imap in --personalities and at least one listen list non-empty. The index lives in its own SQLite file (--imap-db-path, default ./chassis/data/imap.db); message bytes go to the content store the blob plane uses.

On a fleet, --imap-store selects a backend an overlay registers (the hosted build ships postgres, reading TXCO_DB_AUTH_DSN). A shared backend is opened on every node, head or not, so txco://imap/* on any node project into the one index the head serves; a node whose open failed at boot answers txco_imap_disabled until restart. The head keeps one snapshot per selected mailbox and diffs it against the index by modseq, so a client’s next command always sees the latest index and, during IDLE, changes made on other nodes arrive within --imap-sync-interval.

For local development:

txco dev --imap        # 127.0.0.1:1143 (STARTTLS) + 127.0.0.1:1993 (IMAPS), self-signed certificate

Desktop mail clients will not even attempt LOGIN over a plaintext port, so the dev head serves a self-signed certificate (--imap-self-signed) covering loopback and the dev-local hostname patterns, kept at .txco/dev/imap-selfsigned.crt so it is the same one on every restart. Trust it once in the login keychain and clients connect without a prompt:

security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db .txco/dev/imap-selfsigned.crt

Then add the account with SSL on, port 1993. TXCO_IMAP_WIRE_DEBUG=true logs every command and response when a client misbehaves, and every LOGIN attempt logs one imap login line with its outcome.

Accounts: txco://imap/account

EXEC "txco://imap/account"
  WITH username = "paris@pony.example.com",   # <local>@<domain the tenant owns>
       password = "",                         # "" (or omitted on create) generates one
       into = "_acct"
WITHMeaning
username (req)<local>@<domain>. The domain must be a verified hostname binding or a delegated DNS zone of the tenant — the same ownership rule txco://sendmail applies to From:. Usernames are global: one tenant per address.
passwordOmitted: unchanged on update, generated on create. "": generated. Otherwise stored (≥ 8 chars). Only an argon2id hash is kept.
rotatetrue: generate a new password for an existing account and return it once (a “rotate mailbox password” button). With an explicit password it is simply that password.
password_styleHow a generated password looks: token (default) is a 24-character group token (xxxx-xxxx-…, ~116 bits); words is hyphen-joined words from the BIP-39 list (river-galaxy-bamboo-orbit-velvet), 11 bits per word — the phrase a person types into a phone.
password_wordsWord count for words, 4–12, default 5 (55 bits: online guessing is throttled by the head, and an offline attack on the argon2id hash is measured in millennia).
statusactive (default) or disabled.
policyAccount-default policy object (reserved for the next phase).

Result at into (default _imap): {username, created, password?, rotated?}password appears only when it was generated (create, "", or rotate), and only this once; rotated is true when an existing account’s password was regenerated. Creating an account creates its INBOX.

Messages: txco://imap/append

EXEC "txco://imap/append"
  WITH username = "paris@pony.example.com",
       mailbox = "INBOX",                     # a name, or "role:<role>"
       object_key = "msg:" + .msgkey,         # your stable identity for this message
       message = &object(
         "from", "Paris <paris@pony.example.com>",
         "to", "owner@example.com",
         "subject", .subject,
         "text", .normalized_text),
       flags = &array("$Processed"),
       into = "_appended"
WITHMeaning
username, mailboxThe account (must belong to this tenant) and the target mailbox (default INBOX).
object_key (req)Your key. Same key + same content → noop; same key + different content → the old UID is expunged and a new UID allocated (replaced). Bytes under a UID never change.
message{from, to, cc, reply_to, subject, date, message_id, in_reply_to, references, headers{}, text, html, attachments[{name, content_type, size, sha256?}]}. Attachments are references: name/type/size always, a sha256 only when the bytes are in the content store.
flagsInitial flags/keywords (\Seen, $Anything). System flags may be written without the backslash ("Flagged"\Flagged), which keeps txcl string literals escape-free.
internaldateRFC3339; default now. Also the Date: fallback.

Result: {uid, uidvalidity, sha256, size, noop, replaced, mailbox}. Errors land as <into>.error.{code, message} with the run continuing: txco_imap_disabled, txco_imap_no_account, txco_imap_no_mailbox, txco_imap_domain_not_owned, txco_imap_username_taken, txco_imap_invalid_arg, txco_imap_too_large, txco_imap_unsupported (verbatim from / from_sha appends are not available yet).

The op charges fuel per MiB of the rendered message like blob/put. Missing Date: and Message-ID: are synthesized deterministically (from the internal date and the object key) so a message renders byte-identically on every fetch.

The rest of the op family

OpWITHReturns
txco://imap/mailboxusername, name (full path) or id, role, attrs[] (special-use), policy{}, rename_to, delete, reset{id, name, role, attrs, policy, uidvalidity, created, deleted?} — creates when absent, updates otherwise
txco://imap/removeusername, mailbox, uid or object_key{removed, uid}
txco://imap/moveusername, mailbox (source), uid or object_key, to (name or role:<role>){moved, uid, from_uid, mailbox} — a lifecycle step as a folder (“Done”); the message keeps its bytes and flags under a new UID; same folder is a no-op
txco://imap/flagsusername, mailbox, uid or object_key, add[], remove[]{uid, flags[]}
txco://imap/listusername, prefix{mailboxes:[{id, name, role, attrs, policy, uidvalidity, messages, unseen}], count}
txco://imap/messagesusername, mailbox, after (uid cursor), limit (≤ 1000), flags[] (any-of){items:[{uid, object_key, kind, sha256, size, internaldate, flags, subject, from, parts[]}], next, count}
txco://imap/getusername, mailbox, uid or object_key, raw{…row, headers{}, text, html, parts[], raw? (base64)}

txco://imap/append also takes verbatim bytes instead of message{}: from (an envelope path holding base64 RFC 5322, e.g. @lmtp.msg.raw) or from_sha (an RFC 5322 object the tenant already owns in the content store — for instance what txco://sendmail kept with retain = true, ._sendmail.result.recipients.0.sha256, so a mailbox holds the exact reply the stack sent). The exact bytes are retained and every decoded attachment becomes its own owned object. txco://blob/put gained from_sha too: name an owned object (a part the head stored) under your own scheme without re-uploading it.

mailbox in any op is a name (default INBOX) or role:<role> — the stable key a stack set, which survives a client RENAME.

What a client gets

LOGIN (per-IP and per-account throttles, a suspended tenant is refused), NAMESPACE, LIST (implicit parents as \Noselect, CHILDREN, special-use), STATUS, SELECT, FETCH (ENVELOPE, BODYSTRUCTURE, RFC822.SIZE, FLAGS, BODY[…]), SEARCH (UID/sequence, flags, dates, size, Subject/From/To/Cc/Bcc/Message-ID, BODY/TEXT over the stored text), STORE, CREATE (nested, CREATE-SPECIAL-USE), DELETE, RENAME (subtree), APPEND (verbatim, APPENDLIMIT), COPY, MOVE, EXPUNGE, SUBSCRIBE, NOOP/IDLE — a rule’s append shows up in an open client as EXISTS: at once when the op ran on this node, within --imap-sync-interval (15s) when it ran on another node against a shared index. An operator reset of a selected mailbox (a new UIDVALIDITY) ends the session with BYE; the client reconnects and reselects. Bytes under a UID never change; a client APPEND of the same message twice is two UIDs (no object_key), a stack append with the same key is a no-op or a replacement.

Policy: what a stack hears, and when

Per mailbox, seven verbs — append, move_in, move_out, delete, flags, create (of children), rename — each one of:

ModeEffect
denyNO [NOPERM] at the protocol layer, no round trip
localprotocol state only, no event (the default for flags)
observecommit, then tell the _imap stack, fire-and-forget (the default for everything else)
stackask the _imap stack first; NO unless it answers @imap.res.ok = true

Resolution: the mailbox’s policy, then the account’s policy (the default for client-created folders), then the chassis default. A MOVE consults move_out on the source and move_in on the destination and takes the stricter.

The _imap stack is the subscription — while it exists, events flow; without it observe is silent and stack answers NO [UNAVAILABLE].

The envelope

@src            "imap"                       @client.ip
@imap.tenant    (slug)                        @imap.account   (username)
@imap.phase     observe | answer              @imap.op        append | copy | move | expunge | flags | create | delete | rename
@imap.mailbox   {id, name, role}              @imap.dest      {id, name, role}   (copy/move/create/rename)
@imap.uid       (append: the allocated UID)   @imap.objects[] {uid, object_key, sha256, flags}
@imap.msg       {id, date, subject, from[], to[], cc[], headers{}, text, html, sha256, size,
                 parts[{n, name, type, size, sha256}]}                             (append only; no bytes)

Bytes never ride the envelope: the message and each part are in the content store before dispatch, addressable by sha256 through txco://blob/get (or adopted by name with blob/put from_sha). A rule may EMIT @delete = &array("@imap.msg.text", "@imap.msg.html", "@imap.msg.headers") once it has consumed them.

Answering (@imap.phase == "answer")

WHEN @imap.phase == "answer" && @imap.mailbox.role == "readonly"
  EMIT @imap.res.ok = false, @imap.res.code = "cannot", @imap.res.msg = "read-only"

WHEN @imap.phase == "answer" && @imap.op == "append" && @imap.mailbox.role == "knowledge"
  EMIT @imap.res.ok = true, @imap.res.flags = &array("$Ingested"), @imap.res.object_key = "docs/" + @imap.msg.parts.0.sha256

ok absent or false is a NO; code is cannot, limit or unavailable (RFC 5530); msg is shown to the client; on an append, flags[] and object_key land on the stored row. The head waits --imap-resp-timeout (30 s); past it the client gets NO [UNAVAILABLE] and a late answer is discarded. A continuation suspend counts as ok — the run is durable and keeps working on the message.

Observe-lane runs are sampled and bounded (--imap-observe-sample, --imap-observe-max-inflight); a full queue drops the observation rather than delay a client. Both lanes meter fuel like any run.

Mail client settings: IMAP server = the chassis host, port 1993 (dev, self-signed) or 993 (prod) with SSL on, username = the full address, password = the generated one. Apple Mail verifies an outgoing server too when adding an account; there is no submission head yet, so for a local test run Mailpit (mailpit --smtp-auth-accept-any --smtp-auth-allow-insecure) and point outgoing at localhost:1025, or use a real SMTP account.

Mail client settings

The head never routes by name: a connection only needs a certificate to present, and the front proxy serves whichever one it holds for the name the client asked for. So on a fleet where every stack has its own hostname (and its own certificate), the server a mail client should use is the domain of the addressparis@<stack>.stacks.example connects to <stack>.stacks.example, port 993, SSL on. There is no imap. name. Apple Mail guesses imap./mail. and has to be told the server. Thunderbird fetches https://<domain>/.well-known/autoconfig/mail/config-v1.1.xml before it guesses; a stack answers that with two short rules (examples/imap-hello, OPS/imap-demo/080 and 085) and a Thunderbird user then types only the address and password. Thunderbird does not consult RFC 6186 _imaps._tcp SRV records; the dns personality can still publish them for clients that do (--dns-imaps-port, see dns).

On the hosted fleet that is exactly the topology: the edge terminates :993 with the stack’s existing certificate and forwards the session to the head over the private network with the PROXY protocol, the same path the stack’s HTTP takes.

TLS

Three ways to hold the certificate, all supported:

  • A front proxy terminates (recommended for a fleet): Caddy layer4, nginx stream, haproxy on :993, forwarding plaintext to --imap-listen-addrs bound on a private interface, with the PROXY protocol (--imap-proxy-protocol <proxy cidr>) so throttles and the envelope see the real client address.
  • The chassis terminates with the bundled cert manager: --imap-tls-addrs :993 --imap-hostname imap.example.com with the dns personality serving the zone (ACME DNS-01). Works without --web-tls-addr.
  • Cert files: --imap-tls-cert-file / --imap-tls-key-file from certbot or a same-host Caddy.

--imap-insecure-auth permits LOGIN on a plaintext connection; keep it for txco dev and behind-a-proxy deployments only.

Reading the login line under a front proxy. imap login reports tls=false in that topology, because tls describes the head’s own socket and the client’s TLS ended at the proxy. Two further fields make that unambiguous: listener — the address that accepted the session — and proxied, true when a trusted --imap-proxy-protocol source presented a PROXY header. So tls=false proxied=true is the terminated-upstream shape and the credentials were protected; tls=false proxied=false is a genuine cleartext LOGIN, which only --imap-insecure-auth permits.

Flags

FlagDefaultMeaning
--imap-listen-addrs(empty)Plaintext listeners
--imap-tls-addrs(empty)Implicit-TLS listeners
--imap-hostname(empty)Public name, added to the managed certificates
--imap-tls-cert-file / --imap-tls-key-file(empty)Certificate from elsewhere
--imap-proxy-protocol(empty)CIDRs of front proxies whose PROXY v1/v2 header is honoured (the real client IP)
--imap-store / --imap-db-pathsqlite / ./chassis/data/imap.dbThe index; a non-sqlite backend is shared and opened on every node
--imap-sync-interval15sHow often selected mailboxes are re-read for changes from other nodes (0 = next command only)
--imap-insecure-authfalseLOGIN without TLS
--imap-self-signedfalseMint a self-signed certificate at boot (dev; txco dev --imap sets it)
--imap-wire-debugfalseLog every IMAP line at DEBUG, credentials included (dev)
--imap-login-rate10LOGIN attempts per minute, per IP and per username
--imap-max-conns-per-account16Simultaneous authenticated connections
--imap-append-max-bytes32 MiBSize cap for txco://imap/append and a client APPEND (APPENDLIMIT)
--imap-resp-timeout30sAnswer-lane deadline
--imap-observe-sample / --imap-observe-max-inflight1 / 8Observe-lane sampling and concurrency
--imap-proxy-protocol(empty)CIDRs allowed to send a PROXY (v1/v2) header — the real client IP behind a front proxy

Env: TXCO_IMAP_*. Example: examples/imap-hello.

Edit this page · View as markdown