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 aSetError 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 isFolderVisit: 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:
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.
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
headersexplicitly and you get them. partIdandsubPartssurvive an explicit projection inside the tree. A tree without its branches is not a tree, so omittingsubPartsfrombodyPropertiesreads as a mistake rather than as a wish to flatten, and the server keeps them.
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.
headerslists the fields in message order, with their names as written andRawvalues;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 isRaw, soheader:Dateis 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 underurn:ietf:params:jmap:mail; the v2 mail model has none, so a v2-only request for it isinvalidArguments. An absent header property,parentIdorroleisnull, never missing. Thread/changestells births and deaths. A conversation born in a write is reportedcreated, one whose last message left isdestroyed, 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, IMAPEXPUNGE, the console, a sender erasure); everything else isupdated. 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"inmailboxIds(creation references). A mailbox id that is not in the account isinvalidPropertiesnamingmailboxIds, on a create as on an update, never aserverFail. Email/setcomposes from blobs as well as from values. AtextBodyorhtmlBodypart may be ablobId(decoded with its charset); an attachment, or abodyStructureattachment leaf, may be apartIdofbodyValues, stored as a blob of the account. A second body part, a missingbodyValuesentry, or a part with both or neither ofpartIdandblobIdis refused instead of giving an empty body.fromis not required: a draft may be saved before its author is chosen, and is refused only when it is sent.Email/importtakes what the client says.userFlagsis applied (read,starredandansweredthrough the keywords, so the unread counters stay right;importantandmutedafter the import);snoozedandfollowUpDueare refused as onEmail/set, and an unknown field refuses that one entry withinvalidPropertiesnaming it while the other entries import.ifInStateis honoured (stateMismatch), an unknown mailbox isinvalidPropertiesonmailboxIds, ablobIdthat names no blobinvalidPropertiesonblobId. An import repeated after the email was destroyed imports it again instead of answering the id of a message that no longer exists.userFlags.snoozedreads true for a snoozed message, asfollowUpDuealready 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’saccountCapabilities, the session object being{};submissioncarriesmaxDelayedSend: 0(scheduled send is OxiMail’s ownscheduledSendAt, not FUTURERELEASE) andsubmissionExtensionswithSIZEandDSN. Withoutproperties, a client of the RFC mail capability alone gets RFC 8621’s default property list.Mailbox/changesalways carriesupdatedProperties,null, since the change log does not record which properties moved. notFoundis never empty when it should not be. Every unparseable or unknown id in a/getlands innotFoundexplicitly.updatedvalues arenull. Per RFC 8620 §5.3, check membership (id in updated), not truthiness of the value.hasAttachmentmeans 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.
attachmentsis drawn from this message’s body structure (RFC 8621 §4.1.7), so amessage/rfc822part is the attachment and its own parts are not advertised alongside it. Forwarding a mail that itself carriedcontract.pdflists the.eml, notcontract.pdf— the reader is never told they received files the sender never attached. The encapsulated parts remain reachable: parse the.emlblob withEmail/parseand 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 withfetchNestedBodyValues.Email/parsealso answers onshared:<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 isnotFoundotherwise, 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. previewis 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 untiloximail email reindex-bump --allrecomputes it from the raw message.- A thread’s
participantscarry 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 likeEmail.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;nullmeans undetermined, and a client proposes nothing. It is a read projection, never listed.Email/queryfilters on it withdetectedLanguage, strict equality on the stored code —nullmatches nothing, not even an empty string, since not knowing is not a language. Thread/getandThread/changesneed only the mail capability. RFC 8621 §3 makes that pair mandatory, so those two methods carry both capabilities —urn:ietf:params:jmap:mailas primary,v2:threadsas additional — and a client that declares nothing butcore+mailis 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 withproperties.- Anchor pagination is exact.
anchor+anchorOffset+positioninEmail/queryfollow RFC 8620 §5.5 to the letter, including negative positions. - A filtered
/queryChangesspeaks 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
$draftmessage works through the standard destroy+create cycle; the server does not reject draft edits. - Send-to-self delivers.
onSuccessUpdateEmailruns after delivery, and deduplication never happens at the message level — mailing yourself works. - An import without a date takes the message’s own.
Email/importwithoutreceivedAt,oximail migrateand a Takeout import date a message by itsDate: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. IMAPAPPENDwithout a date and SMTP reception are unchanged (RFC 9051 dates anAPPENDat theAPPEND). An import run by an earlier binary could store theDate:header verbatim (RFC 2822 form); sincereceivedAtis sorted and filtered as a string, those messages sorted ahead of the day’s mail in a newest-first list.oximail email normalize-received-atrewrites them through the ingestion’s own normaliser (dry-run by default,--apply), recording anEmailchange per message and aThreadchange per affected thread, andoximail doctorcounts what is left underreceived_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/setnames what it does not know. A property outside the type’s write vocabulary is refused by name,invalidPropertieson a create andinvalidPatchon an update (the write vocabulary); the check is on the exact root, sokeywordsXis no longer taken forkeywords. Theheader:<Name>[:<form>]pseudo-properties of RFC 8621 §4.1.3 stay accepted on a create.replied(a synonym of the answered flag) andarchived, both booleans, are accepted on an update.Thread/setholds 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) isinvalidPropertiesnaming the path and the code point, for exampleto/0/email: control character U+000D (CR) is not allowed; nothing is trimmed. It used to be aserverFail, 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/setcreate is refused withinvalidPropertiesnaming the path, where it used to answerserverFail, 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 isinvalidArgumentsnaming it. - Strictness is a feature. Unknown
accountId→accountNotFound, andaccountIdis required on reads as on writes; a method whose capability is missing fromusing→unknownMethod, ausingentry 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.
Filtering and search
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 bureauused 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.
materielfindsmatérieland 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 afield: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 ofFrom,ToandCc, no longer their stored JSON, where:,"or the keyemailmatched 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:
Activatreturned nothing whereActivatereturned 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, theFrom, 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:totalis 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 aserverFail, never the cap.NOTandORanswer the right messages. A capped word underNOTorORmade the set wrong, not only the total: a message beyond the 10 000 was neither excluded by theNOTnor included by theOR. 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/queryChangesrebuilds the same list. collapseThreadscounts every thread. With threads collapsed,totalcounts the threads that hold at least one matching message, no longer the threads of the 10 000 best by score.
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
Atext 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 returnslimit). Any other argument isinvalidArguments. - Grouping: by
List-Id(RFC 2919) when the message has one, since a platform may vary the sender of one list; otherwise by theFromaddress. 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 onEmail/unsubscribe),oneClick, and that message’shttpsUriandmailtoUri(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 carriesunsubscribedAt,unsubscribeModeandreceivedSinceUnsubscribe, 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 (count0,emailId,lastReceivedAtand 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/unsubscriberecords 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) ormode: "mailto"(it sent the message); the server checks that the message offers that way, sends nothing and records it. Withoutmode, the call is the one-click POST, as before. Sendhttpswhen the user opens the page,mailtoonly once the composed message was actually submitted.Email/unsubscriberefuses an argument it does not know (invalidArguments) instead of ignoring it. - Withdrawing a request:
Email/unsubscribe {key, withdraw: true}, with the entry’skeyand nothing else, removes the request (an undone mailto send, a mistaken click) and contacts no one; the answer sayswithdrawn: true, orfalsewhen there was none. - Brand: an entry carries
brandLogoBlobIdandbrandMarkTypeexactly whenEmail/getwould serve them on itsemailId; 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:
suspicionisnullwhen nothing is wrong with the sender, otherwise{"reasons": [...]}from a closed list:phishing(a message of the entry in phishing bandsuspectedorspam),serverSpam(every message of the entry classified spam by the server and still in Junk; moving one out lifts it) anddmarcFail(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 isEmail/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 thefrom 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 withEmail/setand submits it withEmailSubmission/setin the same request (emailId: "#m"), as RFC 8621 §7 describes; the server materializes the draft with the same builderEmail/senduses, then submits it. It used to refuse withserverFailand point toEmail/send, a method no standard client calls. An email withoutFromis refused at this point,invalidEmailonfrom. - Recipients are judged before sending. An envelope, supplied or generated from
To,CcandBcc, without a recipient isnoRecipients; more than 100 recipients (the bound of one SMTP transaction, the same on both doors, counted before a group’s expansion) istooManyRecipientswithmaxRecipients; an address the SMTP path grammar refuses isinvalidRecipients, listing them. A malformed address used to pass and bounce later. The other refusals are typed too (invalidProperties,forbiddenMailFromfor the envelope sender), andEmailSubmission/querytakes the RFC 8621 §7.3 filter (identityIds,emailIds,threadIds,undoStatus,before,after) and sorts onemailId,threadIdandsentAt. - Scheduled send is first-class.
sendAton a v2Email/setcreate or onEmail/send, andscheduledSendAtonEmailSubmission/set, take the RFC 3339UTCDatestring 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 (envelopeDSN 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.
overQuotacarries 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
mailapp password sends over JMAP. An app password with themailscope may callEmail/send, as it may submit over SMTP: a named scope grants the OxiMail capabilities of the surface it covers (mailgrantsv2:mail,v2:labelsandv2:threads), neverv2:rulesor 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 withurn: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/signedwith a PKCS#7 protocol, orapplication/pkcs7-mime; smime-type=signed-data) and storessigned; 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 ofsmimeStatus,smimeStatusAtDeliveryorsmimeErrorsrecords anEmailchange and pushesStateChanged(RFC 9219 §4.3), so a delta client sees an expiry or a revocation;smimeVerifiedAtalone does not. - The instant “at delivery” is
receivedAtfor a message the server delivered or accepted in submission, never the signer’ssigningTime. A message imported, copied or appended over IMAP, whosereceivedAtthe client chose, is judged at the time of verification. Mail stored before the server recorded where a message came from is judged at itsreceivedAtwhen 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 stayssignedand the worker retries;signed/failedwith 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 thensigned/verifiedwithrevocationChecked: false, exposed, never hidden. - Who signed.
smimeSigner, served underurn: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 fromFrom),profile(mailbox,organization,sponsor,individual,unknown),organization(only for anorganizationorsponsorprofile, whose organization the authority validated) andrevocationChecked. It isnullwhen there is no signer to describe. A signature covers neither the subject, nor the display name, norReply-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.
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 onopenpgpkey.<domain>, then the direct one), then the configured key server, and answersfound(per address:armoredKey,sourceaswkdorkeyserver,host),notFoundandunreachable(per address, a reason amongtimeout,connect,serverError,rateLimited,invalidResponse,policy).notFoundandunreachableare 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 withRecipientKey/set, with thesourceit was given. An unreadable address isinvalidArguments, nevernotFound.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 innotRequestedwith the reason. It answersfingerprint,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) andnotRequested. A private key is refused (invalidArguments) before anything leaves the server.
{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;
protectionreports what the server read,meetsCurrentFloorincluded.fingerprintis computed by the server frompublicPart; at most 32 envelopes per account, 64 KiB each. - The envelope is not served by default.
PrivateKeyEnvelope/getwithoutpropertiesreturns everything butenvelope; 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/changesand push. - A legal hold stands. Destroying or replacing an envelope is
forbiddenwhile the account is under legal hold. A second envelope for the same key isalreadyExistswithexistingId; the public part can be replaced for the same fingerprint (a renewed certificate)./setreports inupdatedonly what the server changed without being asked (updatedAt, andprotectionwhen a new envelope was read).
Read receipts (MDN)
Message Disposition Notifications are the RFC 9007 surface, requested withurn: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
$mdnsentkeyword, and OxiMail holds the RFC letter — theonSuccessUpdateEmailpatch must setkeywords/$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$mdnsentdoes not re-arm the send.