oximail-mail is the JMAP Mail domain crate. It implements JMAP Mail (RFC 8621) on top of the dispatch, capability, and storage machinery provided by oximail-core and oximail-store. It owns the mail data model — Mailbox, Email, Thread, EmailSubmission, Identity, VacationResponse — the JMAP method handlers for each, the single ingestion path every inbound and locally-created message converges on, and the column-projection read path that materializes only the columns a given consumer reads.
Overview
A JMAP client manages a mailbox through this crate’s methods: it lists folders withMailbox/get, fetches messages with Email/get, searches with Email/query, threads conversations with Thread/get, and sends with Email/send / EmailSubmission/set. Each handler implements the JmapMethod trait from oximail-core and is registered by the binary; the dispatcher routes a batched request to it after resolving back-references, validating the account, and checking capabilities.
The crate sits above oximail-core and oximail-store and never reaches sideways into another domain crate. When an inbound message contains a calendar invitation, oximail-mail does not call the calendar crate; it emits a DomainEvent on the EventBus and the calendar subscriber reacts. This keeps the mail and calendar crates decoupled (ADR-011).
All storage goes through the Store, BlobStore, and SearchStore traits, so every method carries the tenant_id and a handler physically cannot read another organization’s mail (ADR-015). The raw RFC 5322 bytes live in the content-addressed blob store; the parsed metadata lives in the structured store. The message is parsed once, at ingestion, and never re-parsed on read (ADR-009).
Features
- Mailbox (RFC 8621 §2): folders with roles (
inbox,sent,drafts,trash,junk,archive), hierarchy, per-folder counts, subscription state, and RFC 9670 sharing rights. - Email (RFC 8621 §4): the message object, with metadata (
from,to,subject,preview,hasAttachment,threadId, keywords) precomputed at ingestion and body parts extracted on demand from the raw blob. - Thread (RFC 8621 §3): the conversation aggregate — an ordered list of email ids grouped by Message-Id string lookup (ADR-019).
- EmailSubmission (RFC 8621 §7): the send request, including scheduled send and the
onSuccessUpdateEmailside effect applied after delivery. - Identity (RFC 8621 §6): the sender addresses an account may use, with signatures and extended profile fields.
- VacationResponse (RFC 8621 §8): the per-account auto-reply singleton.
- The ingestion path: one
ingest_emailconvergence point for every inbound and locally-created message (SMTP inbound, SMTP submission,Email/import,Email/set create, and migration). - Column-projection read path (ADR-062): a single declarative column registry drives both which SQL columns a read must
SELECTand which property groups a client’s request resolves to, so each read materializes only what it needs. - The v1 ↔ v2 bridge:
Email/getandEmail/setaccept the v1 RFC 8621 shape, the modernized v2 shape, or both at once.
How it works
The data model
Each type is a serde struct keyed by a UUIDv7Id. Mailbox carries its role, parent, counts, and sharing rights. Email carries the precomputed metadata; the body (textBody, htmlBody, bodyValues, bodyStructure) is not stored as columns but extracted on demand from the raw blob when a client requests it. Thread is the lightweight aggregate — just an ordered email_ids list — because the grouping work happens at ingestion. VacationResponse is a singleton: its id is always the string "singleton", since there is exactly one per account.
Ingestion: one path, fully checklisted
ingest_email is the single convergence point for every way a message enters the system. It parses the MIME once (with depth and part-count limits — an over-complex message is rejected, not silently truncated), stores the raw RFC 5322 bytes as a content-addressed blob and each MIME part as its own blob, resolves the thread by Message-Id / In-Reply-To / References, writes the structured metadata, indexes the searchable text in Tantivy, recomputes the folder thread counts, records the JMAP changes for Email / Mailbox / Thread, and emits the matching StateChanged and EmailIngested events. A text/calendar part additionally emits ITipReceived for the calendar subscriber.
The path is guarded by an explicit checklist test: every step in ingest_email is asserted by test_ingest_full_checklist, so a step that is added without a matching assertion fails the suite. This is the discipline behind ADR-017 — any message that the server accepted must end up stored somewhere.
The column-projection read path
A naive read loads every column of theemails row even when the caller only needs the flags. The projection path (ADR-062) avoids that. A single declarative registry, EMAIL_COLUMNS, is the source of truth: each entry maps one SQL column to its accessor kind (mandatory or optional, NULL handling), its storage group, and its wire property selector.
Two consumers read that registry. email_select_columns builds the SELECT list for a given projection — using static column names only, so no client string is ever concatenated into a query. EmailProjection::from_jmap_properties resolves a client’s requested properties set to the minimal group set that backs them. MailStore::get_emails_projected joins the two: it builds the narrow SELECT and parses the result by column name, tolerating any subset or order of columns.
A mandatory set of 16 columns — the ones that feed the parser, the security and derivation logic, the encryption descriptor, and the lifecycle state — is always materialized, regardless of projection. Everything else (the inline body, the header JSON, the envelope address lists, the references, the preview, the search tokens, the auth results) is loaded only when a group selects it. The wire output is byte-identical to a full read; projection narrows only which columns are fetched.
The projection path is read-only. Its win is column-load reduction: a request that reads only flags stops loading the header JSON, inline body, envelope, references, preview, search tokens, and auth-results columns. Every emitted value is identical to a full materialization. Per-message change-tracking state (modseq) is deliberately not on the projection surface — it is read by a dedicated side query — so projection can never affect the change-tracking output.
The v1 ↔ v2 bridge
OxiMail advertises two generations of mail capability side by side: the standardurn:ietf:params:jmap:mail (v1, RFC 8621) and OxiMail’s modernized mail extension, urn:oximail:params:jmap:v2:mail (the modernized JMAP mail extension — an Internet-Draft in progress). The v2 surface does not replace v1; the same handler registers the v2 URN as an additional capability. Email/get and Email/set therefore accept the v1 RFC 8621 shape, the modernized v2 shape, or both at once.
v1 and v2 mail capabilities are not mutually exclusive. The core dispatcher keeps a list of mutually-exclusive v1 / v2 pairs, but that list is empty on the current line — mail dropped its pair. A request that lists both
urn:ietf:params:jmap:mail and urn:oximail:params:jmap:v2:mail in using is accepted, and the response is the union of the v1 and v2 properties. A v2 client reading the modernized userFlags / folder / state properties does not have to advertise the v1 capability.state machine (draft → sending → sent, with scheduled, failed, cancelled), native user flags, an audit family, and a causality vector. The v1 keywords ($seen / $flagged / $answered) and the v2 userFlags converge on one stored source of truth: a v1-wire client reading keywords and a v2-wire client reading userFlags see the same facts derived from the same column.
Sending: submission after delivery
Email/send and EmailSubmission/set create the send request. The crate defines the SubmissionSender trait (implemented by the delivery queue in the SMTP crate) and the RecipientExpander trait (implemented by the distribution-groups extension), both declared here to avoid a circular dependency — the mail crate owns the contract, the other crates implement it.
The onSuccessUpdateEmail side effect — the patch that moves a sent draft to the Sent folder and flips its keywords — is applied after delivery completes, not before. This is the fix for the Stalwart send-to-self bug: dedup is mailbox-context-aware and the post-delivery update never lets a message to yourself get swallowed by an early Message-Id match.
Correctness guarantees
These are guarantees the crate upholds, each tied to a specific past failure it refuses to repeat:- Send-to-self is never silently dropped. Dedup is mailbox-context-aware and
onSuccessUpdateEmailruns after delivery, so a message addressed to your own account is delivered and stored rather than deduped away. - Invalid ids go to
notFound. EveryEmail/getid that does not parse or does not exist is reported in thenotFoundarray — never filtered out silently. hasAttachmentreads the stored flag. ThehasAttachmentfilter inEmail/queryand the property inEmail/getuse the attachment flag computed from the actual MIME parts at ingestion, not a scan of extracted body text.- A decrypt failure surfaces, it does not hide. If a body cannot be decrypted on read,
Email/getreturns aserverFail, not a metadata-only response that looks like a legitimately empty message.
Invariants
- Never fail silently. Unparseable or unknown ids land in
notFound; an accepted message is always stored (ADR-017); a decrypt failure returns an error, never an empty body. - Parse once. The message is parsed at ingestion and stored as both raw bytes and structured metadata; reads never re-parse (ADR-009).
- One read mechanism.
Email/getand the IMAP read paths all go through the same projection builder, so the column list and the row parser never drift into two implementations. - No cross-domain imports. The calendar integration goes through the
EventBus, never a direct import.
Configuration
oximail-mail reads no configuration files directly; it consumes the stores and limits the binary resolves. Operator-facing knobs that influence the mail surface — the maximum message size, the per-collection volume caps, the blob and search base paths, and the at-rest encryption key — are documented in the configuration reference. The at-rest encryption model behind the message blobs (the LUKS-style scheme, not end-to-end) is covered in encryption at rest.
Protocol / JMAP surface
oximail-mail implements the JMAP Mail methods. They are advertised under urn:ietf:params:jmap:mail (v1) and, where applicable, urn:oximail:params:jmap:v2:mail (v2).
| Method | Purpose |
|---|---|
Mailbox/get, /set, /query, /queryChanges, /changes | RFC 8621 §2 — folders, roles, hierarchy, counts, sharing. |
Email/get, /set, /query, /queryChanges, /changes | RFC 8621 §4 — fetch, mutate, search, paginate, and diff messages. |
Email/import | RFC 8621 §4.8 — import a raw RFC 5322 message into a mailbox. |
Email/parse | RFC 8621 §4.9 — parse a blob into the Email structure without storing it. |
Email/send | Convenience send (create the message and submit it in one call). |
Email/cancel, Email/wake, Email/reindex | v2 lifecycle — cancel a scheduled send, wake a snoozed message, rebuild the search index for a message. |
EmailSubmission/get, /set, /query, /changes | RFC 8621 §7 — send requests, scheduled send, onSuccessUpdateEmail. |
Thread/get, /changes, /queryChanges | RFC 8621 §3 — the conversation aggregate. |
Identity/get, /set, /changes | RFC 8621 §6 — sender identities and signatures. |
VacationResponse/get, /set | RFC 8621 §8 — the per-account auto-reply singleton. |
SearchSnippet/get | RFC 8621 §5 — highlighted match snippets for a query. |
Quota/get | Per-account storage quota. |
Preferences/get, /set | User preferences singleton. |
Design decisions
- ADR-009 (store raw plus structured) — the message is parsed once at ingestion; the raw RFC 5322 bytes go to the blob store and the parsed metadata to the structured store, so
hasAttachment,preview, andthreadIdare computed once, never on read. - ADR-014 (blob dedup by content hash) — the raw message and each MIME part are addressed by
SHA-256of content, so identical attachments share one blob; two messages with the same Message-Id in different mailboxes are still two distinct Email objects. - ADR-019 (thread by Message-Id string) — threads are resolved by exact Message-Id string lookup stored as plain text, not a hash, so there is zero collision risk.
- ADR-017 (accepted email must deliver) — any message the server accepted ends up stored; the
ingest_emailchecklist test enforces every step of the path. - ADR-062 (raw RFC 5322 plus structured storage cohabitation, draft/projection) — the column-projection read path and the v2 draft body cohabitation: a v2 draft holds its body in native columns until
Email/sendserializes a blob at the SMTP boundary. - ADR-011 (event bus for cross-domain) — the calendar integration is an
ITipReceivedevent, not a direct call into the calendar crate.
Standards
- RFC 8621 — JMAP for Mail. The primary reference for this crate: Mailbox (§2), Thread (§3), Email (§4), search and snippets (§5), Identity (§6), EmailSubmission (§7), VacationResponse (§8).
- RFC 5322 — Internet Message Format. The wire format of the raw bytes stored in the blob store and parsed at ingestion.
- RFC 9670 — JMAP Sharing. The mailbox
shareWith/myRightsfields.
urn:oximail:params:jmap:v2:mail.
Source map & tests
| File (within the crate) | What it holds |
|---|---|
src/email.rs | The Email metadata struct, EmailFilter, EmailSort. |
src/email_v2.rs | The v2 Email extensions: the lifecycle EmailState machine, categorized property groups, user flags, audit, causality. |
src/email_projection.rs | The EMAIL_COLUMNS registry, email_select_columns, and EmailProjection::from_jmap_properties — the column-projection foundation. |
src/mailbox.rs | The Mailbox struct and the MailboxRole enum. |
src/thread.rs | The Thread aggregate (ADR-019). |
src/submission.rs | EmailSubmission, Envelope, and the SubmissionSender / RecipientExpander traits. |
src/identity.rs | The Identity struct. |
src/vacation.rs | The VacationResponse singleton. |
src/ingest.rs | ingest_email — the single ingestion convergence point, guarded by test_ingest_full_checklist. |
src/mail_store.rs | MailStore — the structured-store operations, including get_emails_projected. |
src/methods/ | The JMAP method handlers (email_get, email_set, email_query, mailbox_*, thread_*, identity_*, email_submission_*, vacation_response_*, and the rest). |
src/subscribers/ | The iTIP event subscribers (reply / request / counter senders). |
ingest_email; changing the path means updating that test. The v2 wire shape is pinned by a cross-layer wire-format test.
Status
Implemented and in production at v0.30.0: the full Mailbox / Email / Thread / EmailSubmission / Identity / VacationResponse model, all the JMAP method handlers listed above, the singleingest_email path with its checklist guard, and the v1 / v2 dual-capability bridge on Email/get and Email/set.
The column-projection read path is wired end-to-end on the read paths (real, tested): the store primitive (get_emails_projected / get_email_projected), Email/get (which resolves a properties request to its minimal group set), and the IMAP read paths all drive their projection from real consumer needs. The net wire-behavior delta is zero — projection only narrows which columns are loaded.
A few projection-adjacent items are still spec / deferred and are not wired on the current line: strict rejection of an unknown
Email/get property, the over-emission trim for headerJson, the body-property-without-bodyValues short-circuit, and the inline Email/changes delta (pending the threads / conversations product decision). The v2 handler refactor (the state-machine-enforcing Email/set update path operating on the v2 types) is staged as a follow-up; the v2 types and wire shape are implemented today, and the existing v1 handlers continue to operate on the v1 metadata.