Skip to main content
Mail is the RFC 8621 surface, requested with urn:ietf:params:jmap:mail (plus submission and vacationresponse for sending and auto-reply). This page lists what OxiMail serves and where it goes beyond the RFC. The mechanics shared by every method — batching, back-references, /changes, push — are on JMAP Core.

Objects and methods

OxiMail-specific method extensions on the mail surface: Email/send (one-call submission convenience), Email/cancel (cancel a scheduled/undo-window send), Email/wake (snooze expiry), Email/unsubscribe and Email/getSubscriptions (subscriptions). Reindexing is an admin concern, not a client one. The v2 mail model (urn:oximail:params:jmap:v2:mail) replaces the RFC 8621 role mechanism with a first-class Email.folder enum — inbox, sent, drafts, trash, junk, archive, outbox, snoozed, setAside — while user organisational collections are Label references (JMAP v2); Email/query filters on it with inFolder. Personal folders remain Mailbox objects, addressed with inMailbox.

Folders and their names

Two folders that share a parent cannot share a name (RFC 8621 §2). Creating two homonyms under different parents is legitimate; moving one under the other’s parent is now refused, as is renaming into a sibling’s name, with a SetError that names the property the client sent (name for a rename, parentId for a move) so it can correct what it wrote. The uniqueness is a database constraint, laid after a one-time repair of existing duplicates; over IMAP two identical paths were ambiguous for LIST and SELECT, and a sorting rule fileinto used to pick one at random. A name carries no reserved character: \ / : * ? " < > | (the Windows and SMB set, chosen to be familiar rather than minimal), no control character, no leading or trailing space and no trailing dot, since two folders differing only there are two folders nobody can tell apart. / is the hierarchy separator the server declares to IMAP (NAMESPACE, the LIST delimiter), so a name containing it would designate no unique folder across its own hierarchy. Accents, emoji and inner spaces stay permitted. A sorting rule names its target by the folder itself, not by its text: the move action of a rule takes a mailboxId, or a role (inbox, archive, junk, important) resolved when the rule runs, so renaming or moving a folder never breaks a rule that files into it. Rules stored with a folder name are converted to the folder’s id (or its role) once, when the server starts. See the Rules capability.

New since your last visit

The “+11” next to “43 unread” needs, per person and per folder, the instant the folder was last really displayed, the same on every device. That is FolderVisit: one object per folder, id the folder’s wire name (inbox, setAside, …) or a personal mailbox id, lastViewedAt the instant the server recorded (null: never visited). FolderVisit/markViewed {ids} stamps now — an action method rather than a /set, because a /set would ask the client for a value the server must ignore — and emits StateChanged so the viewer’s other devices refetch their counters; an unchanged visit emits nothing. The object’s state is constant and it has no /changes: a visit is a per-person setting, not a stream. It lives in the viewer’s account, never the owner’s: a visit that advanced the owner’s state would tell the owner, and every other reader of a shared mailbox, when someone opened it, so the type is refused under shared:. The counter itself is an Email/query filter:
It counts the unread messages that entered the folder after the visit. The server stamps an entry date (a stored column, not a wire property) when a message enters a folder — delivery, move, snooze wake-up, follow-up reminder, thread archive, passage to sent — and never on an import, an IMAP APPEND or a draft; receivedAt is never touched, and messages already there before any visit keep no entry date. An unread that was already there at the visit is not new; a message moved, woken or reminded after it is; reading it removes it. newSinceVisit accepts only true, needs exactly one anchor in the same condition, inFolder or inMailbox (a personal mailbox of this account, never a role mailbox), is unsupportedFilter on another account’s query, and Email/queryChanges answers canCalculateChanges: false for it, since visits are not in the Email state. Two flags in v2:mail announce it, folderVisit (the methods and the filter) and folderVisitMailboxes (personal folders too), because a server serving the first without the second exists.

The body tree

Email/get derives everything it says about a message’s body from one depth-first walk of the real MIME tree. That is what makes partId mean the same thing in every property of the same response: the id you read in bodyStructure is the id you find on the attachment, and the key you look up in bodyValues.
bodyValues is empty unless you ask for it. RFC 8621 §4.2 makes the content conditional on fetchTextBodyValues, fetchHTMLBodyValues or fetchAllBodyValues, and OxiMail now holds that letter — the values used to be populated whether or not the arguments were present. A client that never set a fetch* argument and read bodyValues anyway will read an empty map. Set the argument. Email/get and Email/parse run the same selection, so the rule is identical on both, spelled as the RFC spells it: Email/parse used to expect fetchHtmlBodyValues and silently ignored fetchHTMLBodyValues, so an attached message in HTML only opened with an empty bodyValues.
Per-part headers are how you read Content-Type parameters. type is normatively bare — text/calendar and nothing more — so the parameters that carry the meaning are only reachable by asking for the headers of the part: bodyProperties: ["partId", "type", "headers"]. This is the RFC-blessed way to tell a genuine invitation (method=REQUEST) from a plain .ics someone attached, without downloading the blob. Two deviations are deliberate and worth knowing:
  • The default projection strips per-part headers. The default shape stays a subset of the normative §4.2 property list; ask for headers explicitly and you get them.
  • partId and subParts survive an explicit projection inside the tree. A tree without its branches is not a tree, so omitting subParts from bodyProperties reads as a mistake rather than as a wish to flatten, and the server keeps them.
A requested bodyStructure is always served. Two shapes carry no parsed MIME tree — a message the parser refused and fell back to reading flat, and a native draft whose canonical v2 shape is body/html — and used to omit the property silently. They now answer a structure synthesised from the leaves the row does have: one leaf served bare, text plus HTML as multipart/alternative, attachments wrapping it in multipart/mixed. A parsed tree is always served as-is. The body of an attached message, in the same response. RFC 8621 §4.1.4 forbids descending into a message/* part to compute textBody and htmlBody (a phishing mail forwarded as an attachment would otherwise become the body of the report), which left the most common case, a forward with no comment, to a second round trip through Email/parse. Email/get and Email/parse take fetchNestedBodyValues (default false): set, the outer message’s entries come first, unchanged, followed by the textBody / htmlBody entries of each message/rfc822 or message/global part at depth one, each marked viaMessagePartId (the partId of the attached part) and with a partId of the form <viaMessagePartId>.<partId inside the attached message>. Their values join bodyValues under the same fetch* rules and their blobs download. viaMessagePartId is in the bodyProperties vocabulary, and an explicit projection never removes it from a marked entry, which would then read as the outer author’s text. A bare forward (a message/rfc822 root) is served too; a message attached inside the attached one is reached by Email/parse on the first one’s blob. A native v2 draft keeps the RFC 8621 form. bodyStructure is accepted at creation. Email/set takes the RFC 8621 §4.6 form alongside textBody / htmlBody / attachments (never together with them, as §4.6 requires). The server flattens it into one text/plain leaf, one text/html leaf and the attachments by blobId (a cid makes the disposition inline), then builds its own canonical tree, as §4.6 allows. What the builder cannot carry is refused in invalidProperties with the path that holds it, never dropped: per-part header fields, language or location, a partId leaf that is not text/plain or text/html, a second body of the same type, a partId absent from bodyValues, charset or size next to a partId, a leaf with both or neither of partId and blobId.

Behaviours worth knowing

  • Headers are read from the message. headers lists the fields in message order, with their names as written and Raw values; header:<Name> takes the seven forms of RFC 8621 §4.1.2 (asRaw, asText, asAddresses, asGroupedAddresses, asMessageIds, asDate, asURLs) and :all, the last instance by default. Without a form the value is Raw, so header:Date is the field as written, not a date, and a form a field does not take is refused. They used to come from a map stored at ingestion: decoded, sorted by name, text form only. sender (RFC 8621 §4.1.3) is served under urn:ietf:params:jmap:mail; the v2 mail model has none, so a v2-only request for it is invalidArguments. An absent header property, parentId or role is null, never missing.
  • Thread/changes tells births and deaths. A conversation born in a write is reported created, one whose last message left is destroyed, one born and gone in the same operation not at all, on every write path (delivery, Email/set, a copy, a mailbox destroyed, a wake-up, IMAP EXPUNGE, the console, a sender erasure); everything else is updated. The trash purge now records the thread and mailbox it changes, not only the Email.
  • A message can be filed in a mailbox created in the same request, by naming it "#creationId" in mailboxIds (creation references). A mailbox id that is not in the account is invalidProperties naming mailboxIds, on a create as on an update, never a serverFail.
  • Email/set composes from blobs as well as from values. A textBody or htmlBody part may be a blobId (decoded with its charset); an attachment, or a bodyStructure attachment leaf, may be a partId of bodyValues, stored as a blob of the account. A second body part, a missing bodyValues entry, or a part with both or neither of partId and blobId is refused instead of giving an empty body. from is not required: a draft may be saved before its author is chosen, and is refused only when it is sent.
  • Email/import takes what the client says. userFlags is applied (read, starred and answered through the keywords, so the unread counters stay right; important and muted after the import); snoozed and followUpDue are refused as on Email/set, and an unknown field refuses that one entry with invalidProperties naming it while the other entries import. ifInState is honoured (stateMismatch), an unknown mailbox is invalidProperties on mailboxIds, a blobId that names no blob invalidProperties on blobId. An import repeated after the email was destroyed imports it again instead of answering the id of a message that no longer exists. userFlags.snoozed reads true for a snoozed message, as followUpDue already did.
  • The capability values are on the account. The mail values (maxMailboxesPerEmail, mayCreateTopLevelMailbox, false on a read-only or shared account, and the rest of RFC 8621 §1.3.1) are in each account’s accountCapabilities, the session object being {}; submission carries maxDelayedSend: 0 (scheduled send is OxiMail’s own scheduledSendAt, not FUTURERELEASE) and submissionExtensions with SIZE and DSN. Without properties, a client of the RFC mail capability alone gets RFC 8621’s default property list. Mailbox/changes always carries updatedProperties, null, since the change log does not record which properties moved.
  • notFound is never empty when it should not be. Every unparseable or unknown id in a /get lands in notFound explicitly.
  • updated values are null. Per RFC 8620 §5.3, check membership (id in updated), not truthiness of the value.
  • hasAttachment means attachments. The flag is computed from the MIME structure at ingestion, and recomputed when an update adds or removes a part, not from text extraction.
  • An attached message is one attachment, not its contents. attachments is drawn from this message’s body structure (RFC 8621 §4.1.7), so a message/rfc822 part is the attachment and its own parts are not advertised alongside it. Forwarding a mail that itself carried contract.pdf lists the .eml, not contract.pdf — the reader is never told they received files the sender never attached. The encapsulated parts remain reachable: parse the .eml blob with Email/parse and their blobs download normally (every part is stored, nested ones included — what the wire list answers and what is downloadable are two different questions), or ask for them in the same response with fetchNestedBodyValues. Email/parse also answers on shared:<owner>, by the rule the download route already applies: a blob parses when the caller may read at least one of the owner’s mails that carries it (a mailbox shared for reading, or a shared Label), and is notFound otherwise, the answer an unknown blob gets. That is how a colleague taking over a departed person’s mailbox opens the message forwarded as an attachment.
  • preview is what the sender wrote. A message whose own parts carry words is previewed from them; the text of a message it attaches is used only when the envelope has none. When the text part is only the skeleton of the HTML one (the sender stripped the tags and kept the contents of <title> and <style>, proven by the HTML sibling’s stylesheet appearing verbatim in it) or carries no text at all (empty, or the service line “This is a multi-part message in MIME format”), the preview comes from the HTML. Messages already stored keep theirs until oximail email reindex-bump --all recomputes it from the raw message.
  • A thread’s participants carry names. Each correspondent’s display name is taken from any message of the thread that gives one, the most recent winning when two differ, so an external correspondent with no contact card is listed by name, not by address. The name is supplied by the sender and unverified, exactly like Email.from.name: text to display, never proof of identity.
  • Every message knows its language. detectedLanguage (ISO 639-1, "en", "de", …) is detected at ingestion, offline and deterministically, on the same text the index sees; an older message gets it at open time without a backfill; null means undetermined, and a client proposes nothing. It is a read projection, never listed. Email/query filters on it with detectedLanguage, strict equality on the stored code — null matches nothing, not even an empty string, since not knowing is not a language.
  • Thread/get and Thread/changes need only the mail capability. RFC 8621 §3 makes that pair mandatory, so those two methods carry both capabilities — urn:ietf:params:jmap:mail as primary, v2:threads as additional — and a client that declares nothing but core + mail is served. The richer thread verbs (query, queryChanges, set, archive, merge, split) stay v2 extensions. The RFC’s {id, emailIds} shape is a strict subset of what the object serves: select it with properties.
  • Anchor pagination is exact. anchor + anchorOffset + position in Email/query follow RFC 8620 §5.5 to the letter, including negative positions.
  • A filtered /queryChanges speaks only of the filter. Messages that never matched the filter are not named among the removed.
  • Drafts can be rewritten. A body update on a $draft message works through the standard destroy+create cycle; the server does not reject draft edits.
  • Send-to-self delivers. onSuccessUpdateEmail runs after delivery, and deduplication never happens at the message level — mailing yourself works.
  • An import without a date takes the message’s own. Email/import without receivedAt, oximail migrate and a Takeout import date a message by its Date: header (normalised to UTC) when the source provides no usable date; “now” is the last resort, so an imported batch no longer lands entirely at the minute of the import. IMAP APPEND without a date and SMTP reception are unchanged (RFC 9051 dates an APPEND at the APPEND). An import run by an earlier binary could store the Date: header verbatim (RFC 2822 form); since receivedAt is sorted and filtered as a string, those messages sorted ahead of the day’s mail in a newest-first list. oximail email normalize-received-at rewrites them through the ingestion’s own normaliser (dry-run by default, --apply), recording an Email change per message and a Thread change per affected thread, and oximail doctor counts what is left under received_at_format.
  • An encoded display name with commas is one sender. A From: whose name is a RFC 2047 encoded word containing commas no longer explodes into empty senders.
  • Email/set names what it does not know. A property outside the type’s write vocabulary is refused by name, invalidProperties on a create and invalidPatch on an update (the write vocabulary); the check is on the exact root, so keywordsX is no longer taken for keywords. The header:<Name>[:<form>] pseudo-properties of RFC 8621 §4.1.3 stay accepted on a create. replied (a synonym of the answered flag) and archived, both booleans, are accepted on an update. Thread/set holds the same rule. A control character (U+0000 to U+001F, or DEL) in an address, a display name or the subject (from, to, cc, bcc, replyTo, sender, subject, on a create or an update) is invalidProperties naming the path and the code point, for example to/0/email: control character U+000D (CR) is not allowed; nothing is trimmed. It used to be a serverFail, which a well-behaved client retries.
  • A control character is the client’s error, not the server’s. A C0 character (U+0000 to U+001F) or DEL in an address, a display name or the subject of an Email/set create is refused with invalidProperties naming the path, where it used to answer serverFail, which a well-behaved client retries. The same judge refuses the same bytes in a contact card, so the address book cannot hold an address mail would refuse.
  • Two errors for two mistakes. A filter the server cannot evaluate is unsupportedFilter; a value of the wrong shape on a supported filter is invalidArguments naming it.
  • Strictness is a feature. Unknown accountId → accountNotFound, and accountId is required on reads as on writes; a method whose capability is missing from using → unknownMethod, a using entry the server does not support → unknownCapability; a bad back-reference → invalidResultReference; a request mixing the caller’s own account and a shared account is refused on the foreign call, one effective account per request. The server is the reference implementation and does not have a lenient mode.
Email/query supports the RFC 8621 filter tree (inMailbox, from/to/subject/text, hasKeyword, hasAttachment, dates, sizes) with AND/OR/NOT combinators, plus the v2 conditions (inFolder, read, newSinceVisit, detectedLanguage, spam verdict). Full-text conditions run against the Tantivy index — searchable the moment SMTP answered 250 OK (accepted means searchable). Four things the engine does that were measured wrong on the fleet:
  • A multi-word query requires every word. matériel de bureau used to search matériel OR de OR bureau, so a query of several words was broader than each of them and a frequent word brought the whole mailbox back. All words are required now, with a fallback to any-word when no message carries them all: someone who finds nothing reformulates, and each reformulation is a round trip. Stemming is deliberately absent (a wrong stem list is worse than none, and requiring every word makes stop words cost nothing).
  • An accent does not decide. materiel finds matériel and the reverse; the analyser folds diacritics on both the indexed text and the query.
  • A colon is text. re: facture, a time, a URL used to be read as a field name by the query parser and silently fell back to a worse method; they are searched as words now, and so is a field: prefix typed in the query, never read as the address of a field. A query that holds nothing the index can read (« : » or « ” » alone) is not refused yet: it is answered by the fallback described below, which compares the names and addresses of From, To and Cc, no longer their stored JSON, where :, " or the key email matched every message and brought the whole mailbox back.
  • A word is found by its beginning. A word used to be found only by its whole spelling: Activat returned nothing where Activate returned hundreds, and a client searching as the person types stayed at zero until the last letter (German compounds made it glaring). The last term of a typed sequence of words now also matches the words that start with it, from three characters; the terms before it stay exact. A trailing * or % asks for the same explicitly, instead of returning zero without an error as it did. A query written in the query language (a quoted phrase, OR, an exclusion with -) keeps its exact meaning. This is mechanical, not stemming: it is the same in every language, and at worst brings back too much, never something wrong.

What each condition searches

The index keeps five text fields per message: the subject, the From, To and Cc names and addresses, and the body. The two RFC 8621 conditions read them differently, and that difference is now held: A condition inside an operator is answered by the index too. A text or body condition under AND, OR or NOT (a search restricted to a folder, a search combined with a date, a shared mailbox’s search, which the server wraps in an AND) used to be matched on the 256-character preview, so a word further down the body was found by the search alone and lost as soon as a folder was added. Every condition of the tree is now resolved in the index, at the root or inside an operator, and the database only renders the tree around it. IMAP SEARCH shares the same resolution.

Large result sets

The index hands the database at most its 10 000 best candidates for a word. Past that cap, the answers used to be computed on those 10 000 alone and presented as complete. They are now exact:
  • total is the real count. A search that matches more than 10 000 messages reports how many it matches, counted in the index over the messages the rest of the filter allows (a shared viewer’s scope included), instead of stopping at 10 000. A count that fails is a serverFail, never the cap.
  • NOT and OR answer the right messages. A capped word under NOT or OR made the set wrong, not only the total: a message beyond the 10 000 was neither excluded by the NOT nor included by the OR. The server now lists the messages whose fate the word decides and asks the index, without a limit, which of them carry it.
  • A sort by date lists every match. Under any sort but relevance, a word carried by more than 10 000 messages yields every one of them in the order asked, the most recent first for a newest-first list; a recent message could be missing before, with nothing to say so. Email/queryChanges rebuilds the same list.
  • collapseThreads counts every thread. With threads collapsed, total counts the threads that hold at least one matching message, no longer the threads of the 10 000 best by score.
Only relevance keeps the 10 000 best by score, since its ranking lives there; its total is counted all the same.

Ranking and highlights

Email/query can sort by relevance: the comparator relevance returns the engine’s ranking, is accepted only as the first comparator and only with a text or body filter (unsupportedSort otherwise, since there is no ranking without a query), is refused together with collapseThreads: true (unsupportedSort, since a list collapsed to one message per thread renders no ranking), and must be requested explicitly, because a server does not disobey the sort it was given (RFC 8621 §4.4.2). sortProperties in the v2:mail capability lists it. The engine used to score subject, addresses and body as one text, so a word in a legal footer weighed what a word in the subject weighs, and short repetitive notifications came first. The index now weighs the field itself: a word in the subject counts three times, in the sender’s name or address twice, in To, Cc or the body once. The relevance order further weighs the score by the message’s age (the score halves at 60 days and keeps decreasing slowly, so an old message that matches much better still comes first). A message carrying the whole word ranks ahead of one that only carries a longer word starting the same way. The other sort orders are unchanged, and so is the set of messages found. Email/queryChanges refuses a sort as Email/query does. A sort the store refuses (relevance without a text condition, an unknown property) is unsupportedSort, where it used to be a serverFail, and a sort by spamScore from someone who is not a tenant administrator is refused before any work: the positions of the added entries would reveal the same order the comparator is closed to them for. SearchSnippet/get marks the word the search found. It returns highlighted fragments for result lists, cut and folded by the index’s own analyser: reunion marks Réunion, a typed beginning (factu) marks the whole word facture, and letters inside another word (art in départ) or a query operator (OR, an excluded -word) are no longer marked. The fragments come from the subject and the stored preview, never from an excerpt of the body.

When the index cannot answer

A text or body condition the index did not answer (no index open, a query it failed on, or one holding nothing it can read) is answered by a fallback on the subject, the addresses and the preview, which misses any word further down the body. The answer says so: Email/query and Email/queryChanges add fullTextIndexUnavailable: true, only for a request whose using lists urn:oximail:params:jmap:v2:mail (RFC 8620 §1.8), absent otherwise. A client can then tell its user that the search was partial instead of presenting a short list as complete. ChatMessage/query and Note/query have no such fallback and answer serverFail while the index is unopenable. The operator side, from the state gauge to the remedy, is on mailboxes and storage.

Subscriptions

Email/getSubscriptions (v2 mail capability) answers “what am I subscribed to?” in one call: the senders of the newsletters the account received, one entry per list, from messages that carry a usable List-Unsubscribe (an https or mailto URI, the same judgement as the listUnsubscribe property).
  • Arguments: windowDays (90 by default, 1 to 365), position, limit (100 by default; above 500 the server answers 500 and returns limit). Any other argument is invalidArguments.
  • Grouping: by List-Id (RFC 2919) when the message has one, since a platform may vary the sender of one list; otherwise by the From address. Never by domain alone.
  • Counted: messages in a folder other than Sent, Drafts and Trash. A list is returned once it has at least 2 such messages in the window, so one receipt that happens to carry the header is not a subscription.
  • Each entry: key, name, email, count, unreadCount, junkedCount (messages of that list in Junk: a newsletter moved there is still a subscription), lastReceivedAt, emailId (the latest message, to chain on Email/unsubscribe), oneClick, and that message’s httpsUri and mailtoUri (null when the list offers no such way), so a client opens the page from the click without reading the message.
  • Order: most messages first, then most recent.
  • Lists left: once the account asked to leave a list through Email/unsubscribe, its entry carries unsubscribedAt, unsubscribeMode and receivedSinceUnsubscribe, the messages that arrived after the request (a list that does not honour it). It stays listed while the request is within the window, even with no message (count 0, emailId, lastReceivedAt and both URIs null, name and address as recorded at the request), or while it still sends; the 2-message threshold does not apply to it.
  • Recording a request: Email/unsubscribe records it after its own one-click POST is accepted (2xx). For the other ways, the client does the work and says so: mode: "https" (it opened the page) or mode: "mailto" (it sent the message); the server checks that the message offers that way, sends nothing and records it. Without mode, the call is the one-click POST, as before. Send https when the user opens the page, mailto only once the composed message was actually submitted. Email/unsubscribe refuses an argument it does not know (invalidArguments) instead of ignoring it.
  • Withdrawing a request: Email/unsubscribe {key, withdraw: true}, with the entry’s key and nothing else, removes the request (an undone mailto send, a mistaken click) and contacts no one; the answer says withdrawn: true, or false when there was none.
  • Brand: an entry carries brandLogoBlobId and brandMarkType exactly when Email/get would serve them on its emailId; an entry without a message has none. A new request for the same list replaces the previous one; requests are erased with the account.
  • Suspicion: suspicion is null when nothing is wrong with the sender, otherwise {"reasons": [...]} from a closed list: phishing (a message of the entry in phishing band suspected or spam), serverSpam (every message of the entry classified spam by the server and still in Junk; moving one out lifts it) and dmarcFail (DMARC failed on the latest message). Unsubscribing from spam confirms the address to the sender, and the page may be a trap: on a suspicious entry, offer to review the messages (keep in Junk, delete, not spam), not to unsubscribe. Only the band is used, never a score; an entry without a message has no suspicion.
  • The owner of the account only; under shared access the call is forbidden. It writes nothing: blocking a sender or filing its future messages is a rule, acting on the past ones is Email/set.

Keywords and flags

JMAP keywords ($seen, $flagged, $draft, $answered, $junk, custom) are the same state IMAP flags read and write — one store, two protocols. $encrypted marks a delivered message whose root is encrypted (multipart/encrypted, OpenPGP/MIME, or application/pkcs7-mime with enveloped-data or authEnveloped-data, S/MIME), so a message list can show it without reading the body. No standard defines such a marker; the server sets it at delivery the way it sets $hasattachment, under the name KMail already uses, opening nothing and decrypting nothing (inline OpenPGP inside a text body is not seen). An import, an IMAP APPEND or a draft keeps the keywords its client wrote. Messages delivered before are marked by oximail email backfill-encrypted (dry run by default, --apply, one --account or all, --json), which records an Email change per message. The spam pipeline files with $junk, and a client moving a message to Junk or out of it trains the filter in both directions; user Labels are a separate v2 object (JMAP v2).

Sending

The submission pipeline validates the from against the account’s identities (envelope and header — forging a colleague’s address fails with forbiddenFrom), stores the message and hands it to the delivery queue. onSuccessUpdateEmail/onSuccessDestroyEmail run after delivery is accepted, in the same request’s response. A refused submission surfaces its specific SetError (a forbidden envelope sender, an unsupported envelope parameter) rather than a serverFail wrapping it.
  • A draft created over JMAP is sent by EmailSubmission/set. A standard client creates the draft with Email/set and submits it with EmailSubmission/set in the same request (emailId: "#m"), as RFC 8621 §7 describes; the server materializes the draft with the same builder Email/send uses, then submits it. It used to refuse with serverFail and point to Email/send, a method no standard client calls. An email without From is refused at this point, invalidEmail on from.
  • Recipients are judged before sending. An envelope, supplied or generated from To, Cc and Bcc, without a recipient is noRecipients; more than 100 recipients (the bound of one SMTP transaction, the same on both doors, counted before a group’s expansion) is tooManyRecipients with maxRecipients; an address the SMTP path grammar refuses is invalidRecipients, listing them. A malformed address used to pass and bounce later. The other refusals are typed too (invalidProperties, forbiddenMailFrom for the envelope sender), and EmailSubmission/query takes the RFC 8621 §7.3 filter (identityIds, emailIds, threadIds, undoStatus, before, after) and sorts on emailId, threadId and sentAt.
  • Scheduled send is first-class. sendAt on a v2 Email/set create or on Email/send, and scheduledSendAt on EmailSubmission/set, take the RFC 3339 UTCDate string only: the Unix integer accepted during a three-step wire migration is retired, and an integer is refused with an error naming the accepted form. A scheduled submission is queued and cancellable (Email/cancel) until it leaves; disabled accounts’ scheduled sends are cancelled at the source. A delivery status request (envelope DSN parameters) survives on a scheduled submission, so asking for a receipt no longer forces an immediate send.
  • The upload cap is real. The maxConcurrentUpload (4) the session advertises is enforced on both upload routes; the fifth concurrent upload answers the RFC 8620 limit error naming it.
  • A quota refusal says what and by how much. overQuota carries a description naming the quota exceeded and the size refused against its limit; the wire shape without it is unchanged (quotas).
  • Sending a draft leaves no unread behind. The draft is marked read while still in Drafts, before it moves to Sent; the Drafts unread counter used to grow by one with every message sent. Counters that drifted before the fix are repaired by oximail mailbox recount --all.
  • A mail app password sends over JMAP. An app password with the mail scope may call Email/send, as it may submit over SMTP: a named scope grants the OxiMail capabilities of the surface it covers (mail grants v2:mail, v2:labels and v2:threads), never v2:rules or Sieve, since a rule can redirect mail. A custom list of capabilities stays exact.

Signed messages (S/MIME)

OxiMail verifies the S/MIME signature of the mail it receives and serves the verdict as RFC 9219 defines it, requested with urn:ietf:params:jmap:smimeverify. The verification is a dated authentication fact; it never decides acceptance, filing or the spam score. The four properties, and the filter conditions hasSmime, hasVerifiedSmime and hasVerifiedSmimeAtDelivery, exist only for a client that declared the capability in using (RFC 8620 §1.8): asked without it, a property is invalidArguments and a condition unsupportedFilter, each naming the capability. With it, a requested property is always present, null for a message that is not S/MIME.
  • Verified after delivery, kept true over time. Ingestion reads only the root Content-Type (multipart/signed with a PKCS#7 protocol, or application/pkcs7-mime; smime-type=signed-data) and stores signed; a worker verifies afterwards. Only the root of the message counts: a signed part nested in a forwarded message says nothing about the message that carries it. The verdict is then re-evaluated at the earliest of the certificate’s expiry, the next update of a revocation list it used, and a retry. A change of smimeStatus, smimeStatusAtDelivery or smimeErrors records an Email change and pushes StateChanged (RFC 9219 §4.3), so a delta client sees an expiry or a revocation; smimeVerifiedAt alone does not.
  • The instant “at delivery” is receivedAt for a message the server delivered or accepted in submission, never the signer’s signingTime. A message imported, copied or appended over IMAP, whose receivedAt the client chose, is judged at the time of verification. Mail stored before the server recorded where a message came from is judged at its receivedAt when that is an RFC 3339 date.
  • Revocation by CRL, never OCSP. The lists are fetched only for the distribution points of a certificate path that already validated, and cached until their nextUpdate; the server never follows an address the sender’s certificate points to before an anchor has validated the path, never asks OCSP (which would tell the authority who reads what, and when) and never follows AIA. While no list has ever been obtained for a path, the status stays signed and the worker retries; signed/failed with the CRL reason comes only when no valid list covers the instant and the fetch fails. The operator can switch revocation checking off (email authentication): the signature is then signed/verified with revocationChecked: false, exposed, never hidden.
  • Who signed. smimeSigner, served under urn:oximail:params:jmap:v2:mail (refused by name without it), describes the signer of a verified signature: issuer, notBefore, notAfter, addresses (the certificate’s, to show when they differ from From), profile (mailbox, organization, sponsor, individual, unknown), organization (only for an organization or sponsor profile, whose organization the authority validated) and revocationChecked. It is null when there is no signer to describe. A signature covers neither the subject, nor the display name, nor Reply-To, and a mailbox-validated certificate is easy to obtain: a client says “Signed by <address>”, not “Verified”.
  • Live updates. A push-patches subscription may name these properties; the flows no request drives use the capabilities the account holds, so the subscription is no longer refused whole.
Signed mail stored before verification existed is marked by oximail smime backfill, after which the worker verifies it at its pace; oximail smime verify re-verifies from the bytes.

Signed messages (OpenPGP)

The OpenPGP signature at the root of a received message (multipart/signed with application/pgp-signature, or a cleartext-signed text) gets a verdict on the same model, under urn:oximail:params:jmap:v2:mail since no RFC defines one: pgpStatus (null without an OpenPGP signature at the root, signed when it cannot be verified, signed/verified, signed/failed), pgpStatusAtDelivery, pgpErrors (a closed list: unknownKey, unsupportedSignature, malformedSignature, badSignature, keyExpired, keyRevoked, fromNotInKey), pgpVerifiedAt and pgpSigner. The signature is verified against the account’s own keys only, never a directory, and again when they change. Email/query selects on it with hasPgp (pgpStatus is not null, whatever the verdict), hasVerifiedPgp (pgpStatus is signed/verified) and hasVerifiedPgpAtDelivery (the same on pgpStatusAtDelivery); false selects every other message, unsigned ones included. Without the capability in using, each condition is unsupportedFilter. Because every server announces that capability, the flag pgpFilters: true in its object says that these conditions are served. A “signed” filter covering both techniques is {"operator": "OR", "conditions": [{"hasSmime": true}, {"hasPgp": true}]}, with both capabilities in using.

OpenPGP keys

RecipientKey holds the OpenPGP public keys of correspondents the account keeps: email, armoredPublicKey, fingerprint, algorithm, userId, expiresAt and source (keyserver, wkd, signed-email, manual-import). The key material is immutable: a refreshed key has a new fingerprint and travels as a create plus a destroy; only userId, expiresAt and source update. The lookup of a new key and the publication of one’s own go through the server, so the key server no longer learns the user’s IP address along with the address looked up:
  • RecipientKey/lookup {emails} (at most 50 addresses) consults the recipient domain’s Web Key Directory first (the advanced method on openpgpkey.<domain>, then the direct one), then the configured key server, and answers found (per address: armoredKey, source as wkd or keyserver, host), notFound and unreachable (per address, a reason among timeout, connect, serverError, rateLimited, invalidResponse, policy). notFound and unreachable are distinct: a key server that could not be reached is never reported as “no key”, which would let the user write in clear. A key is found only if it names the address and its self-signatures verify. The method stores nothing; the client keeps the key with RecipientKey/set, with the source it was given. An unreadable address is invalidArguments, never notFound.
  • PgpKey/publish {armoredPublicKey} sends the key to the key server and requests verification only for the key’s addresses the account owns (its primary address and its exact sendable aliases), listing the others in notRequested with the reason. It answers fingerprint, host, status (per address, as the key server reports it after the request: unpublished, pending, published, revoked), verificationRequested (only the addresses the key server says are pending) and notRequested. A private key is refused (invalidArguments) before anything leaves the server.
The session announces the capability with {lookup, publish, keyserver}. A gesture the operator switched off in [pgp_directory] answers forbidden, so a client reads the session and stays silent instead of failing on every recipient; too many calls for an account answer the RFC 8620 limit error.

Private keys kept on the server

The owner of a personal account can keep on the server a copy of their OpenPGP or S/MIME private key, already protected by its passphrase, so a new device finds it (urn:oximail:params:jmap:privatekeys, also https://oximail.ch/jmap/privatekeys; PrivateKeyEnvelope/get, /set, /changes, on the personal account only). The server never decrypts it, never runs a key derivation and never sees the passphrase; it checks the structure alone and refuses a key stored unprotected.
  • What is accepted. An OpenPGP transferable secret key whose every secret packet is protected with AES in OCB mode under Argon2 (v4 and v6 keys), or a PKCS#8 encrypted private key under PBES2 (PBKDF2 with an explicit SHA-2 function, or scrypt; a salt of at least 16 octets; AES-CBC), with the matching certificates for S/MIME. The derivation must meet a floor (Argon2 cost 196 608, PBKDF2 600 000 iterations, scrypt 2^20) and stay under a ceiling; protection reports what the server read, meetsCurrentFloor included. fingerprint is computed by the server from publicPart; at most 32 envelopes per account, 64 KiB each.
  • The envelope is not served by default. PrivateKeyEnvelope/get without properties returns everything but envelope; naming it, adding, replacing or removing a key requires a recent authentication of the session that asks, by password or by passkey, within ten minutes. An app password has no session and never qualifies.
  • Every retrieval is visible. Reading the envelope advances the type’s state (lastRetrievedAt), so the account’s other devices learn of it through /changes and push.
  • A legal hold stands. Destroying or replacing an envelope is forbidden while the account is under legal hold. A second envelope for the same key is alreadyExists with existingId; the public part can be replaced for the same fingerprint (a renewed certificate). /set reports in updated only what the server changed without being asked (updatedAt, and protection when a new envelope was read).

Read receipts (MDN)

Message Disposition Notifications are the RFC 9007 surface, requested with urn:ietf:params:jmap:mdn. Both directions are served. Reading the receipts you were sent. Inbound MDNs are linked onto the sent message they answer at SMTP ingest, paired on Original-Message-ID and exposed as Email.mdnBlobIds. MDN/parse turns such a blob into the structured MDN object — disposition, originalMessageId, finalRecipient, reportingUA, originalRecipient, mdnGateway, error, extensionFields — so a client renders “your message was displayed on …” without reimplementing RFC 8098 itself. Verdicts are per blob, never a method-level collapse: each id lands in parsed, notParsable (it parses as mail but is not an MDN, or does not parse at all — RFC 9007 draws no finer line), or notFound. Empty groups are served as null, which is the RFC’s own shape. Sending one. MDN/send composes an RFC 8098 receipt for a received message and hands it to the outbound queue, with disposition and optional subject, textBody, reportingUA, finalRecipient (which must be an address this account may send as, forbiddenFrom otherwise), includeOriginalMessage, and extensionFields. Two properties of that method are worth knowing before you build on it:
  • Nothing sends an MDN automatically. Emission is an explicit user gesture, and this method is the gesture. The receipt goes out with a null envelope sender (RFC 8098 §3) and does not generate follow-up traffic if it fails.
  • “Already sent” is not client-erasable. RFC 9007 anchors the anti-duplicate check on the $mdnsent keyword, and OxiMail holds the RFC letter — the onSuccessUpdateEmail patch must set keywords/$mdnsent: true, refused by name otherwise, so an RFC-literal third-party client works unmodified. But it checks a server-side audit timestamp alongside the keyword, written atomically with the patch. Clearing $mdnsent does not re-arm the send.
For the classic-protocol view of the same data, see legacy protocols; for v2-only mail extensions (labels, snooze, saved messages), see JMAP v2.