Skip to main content
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 with Mailbox/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 onSuccessUpdateEmail side 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_email convergence 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 SELECT and which property groups a client’s request resolves to, so each read materializes only what it needs.
  • The v1 ↔ v2 bridge: Email/get and Email/set accept 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 UUIDv7 Id. 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 the emails 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 standard urn: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.
The v2 model adds a categorized property layout, a lifecycle state machine (draftsendingsent, 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 onSuccessUpdateEmail runs after delivery, so a message addressed to your own account is delivered and stored rather than deduped away.
  • Invalid ids go to notFound. Every Email/get id that does not parse or does not exist is reported in the notFound array — never filtered out silently.
  • hasAttachment reads the stored flag. The hasAttachment filter in Email/query and the property in Email/get use 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/get returns a serverFail, 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/get and 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).
MethodPurpose
Mailbox/get, /set, /query, /queryChanges, /changesRFC 8621 §2 — folders, roles, hierarchy, counts, sharing.
Email/get, /set, /query, /queryChanges, /changesRFC 8621 §4 — fetch, mutate, search, paginate, and diff messages.
Email/importRFC 8621 §4.8 — import a raw RFC 5322 message into a mailbox.
Email/parseRFC 8621 §4.9 — parse a blob into the Email structure without storing it.
Email/sendConvenience send (create the message and submit it in one call).
Email/cancel, Email/wake, Email/reindexv2 lifecycle — cancel a scheduled send, wake a snoozed message, rebuild the search index for a message.
EmailSubmission/get, /set, /query, /changesRFC 8621 §7 — send requests, scheduled send, onSuccessUpdateEmail.
Thread/get, /changes, /queryChangesRFC 8621 §3 — the conversation aggregate.
Identity/get, /set, /changesRFC 8621 §6 — sender identities and signatures.
VacationResponse/get, /setRFC 8621 §8 — the per-account auto-reply singleton.
SearchSnippet/getRFC 8621 §5 — highlighted match snippets for a query.
Quota/getPer-account storage quota.
Preferences/get, /setUser preferences singleton.
See JMAP Mail for the client-facing protocol walk-through, when available.

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, and threadId are computed once, never on read.
  • ADR-014 (blob dedup by content hash) — the raw message and each MIME part are addressed by SHA-256 of 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_email checklist 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/send serializes a blob at the SMTP boundary.
  • ADR-011 (event bus for cross-domain) — the calendar integration is an ITipReceived event, not a direct call into the calendar crate.
ADRs live in the OxiMail server repository; see the ADR index.

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 / myRights fields.
The modernized v2 mail extension — the categorized property layout, the lifecycle state machine, and the native user flags — is OxiMail’s own modernized JMAP mail extension, an Internet-Draft in progress. It is advertised under urn:oximail:params:jmap:v2:mail.

Source map & tests

File (within the crate)What it holds
src/email.rsThe Email metadata struct, EmailFilter, EmailSort.
src/email_v2.rsThe v2 Email extensions: the lifecycle EmailState machine, categorized property groups, user flags, audit, causality.
src/email_projection.rsThe EMAIL_COLUMNS registry, email_select_columns, and EmailProjection::from_jmap_properties — the column-projection foundation.
src/mailbox.rsThe Mailbox struct and the MailboxRole enum.
src/thread.rsThe Thread aggregate (ADR-019).
src/submission.rsEmailSubmission, Envelope, and the SubmissionSender / RecipientExpander traits.
src/identity.rsThe Identity struct.
src/vacation.rsThe VacationResponse singleton.
src/ingest.rsingest_email — the single ingestion convergence point, guarded by test_ingest_full_checklist.
src/mail_store.rsMailStore — 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).
The ingestion path carries a single checklist test that asserts every step of 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 single ingest_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.