legacy feature provides them as translation layers over the same JMAP data model — an IMAP flag change and a JMAP keyword change are the same mutation, seen through two protocols. There is no second mail store to drift out of sync.
IMAP (RFC 9051)
The IMAP bridge implements IMAP4rev2 on port 993 (implicit TLS), and IMAP4rev1 (RFC 3501) for the clients that never sendENABLE IMAP4rev2, which is most of them: the responses whose grammar differs between the two revisions follow what the session enabled. Points that matter to an operator:
- Same data, live. A message moved in the webmail appears moved to every IMAP session; sibling-connection changes are announced correctly — a shrinking mailbox is announced as
EXPUNGElines (old-view numbering, descending), never as a bare decreasingEXISTS, which RFC 9051 forbids and which multi-connection clients like eM Client punish with a full folder resync. - Incremental resync.
CONDSTOREandQRESYNCare supported, includingVANISHEDresponses, so a reconnecting client fetches deltas instead of re-listing the folder. - UIDs are durable. Per-mailbox UID uniqueness is enforced at the storage layer;
UID EXPUNGEhonors its uid-set. - Honest identification. The
IDcommand reports the server name and its real version, and logs the client’s declared identity — when a specific client misbehaves, the logs can name it. - App passwords. Clients that cannot do the web login flow authenticate with per-client app passwords; failures feed fail2ban like every other listener. A session opened with an app password whose scope opens IMAP (
full,mail) sees the shared namespaces below as the account password does; they used to open for the account password only, so moving a client to an app password hid the team’s mailboxes. - Which device is which, in the log. The IMAP login line carries the session, the client address and the id of the app password that opened it, and the session’s closing line names the user instead of
-; the SMTPAUTHlines carry the credential’s id and the client address too. A CalDAV or CardDAV client sends its credentials with every request, so a line per request would bury the log: aDAV sign-inline is written the first time a credential is seen from an address, then at most once an hour for the same account, credential and address, naming the protocol, the account, the secret (an app password’s id, or the account password), the client address and the client the request announced. - What is advertised is served. A conformance battery run read-only against a real mailbox found a server that answered
OKto everything; it now refuses what it does not understand (an unknown command, an unknownFETCHorSTATUSitem, a malformed search date, an unsupported charset, a zero sequence number, a missing mailbox inSTATUS), answersBADto a command outside its state (SEARCHbeforeSELECT,STATUSbeforeLOGIN), and honours the four capabilities it announced without holding them:THREAD=REFERENCESandTHREAD=ORDEREDSUBJECTbuild a real RFC 5256 tree from Message-ID, References and In-Reply-To instead of a flat sequence,SEARCH MODSEQ,STATUS (SIZE)andQUOTA/GETQUOTAROOT(RES-STORAGE, backed by the storage quota) do what they say.EXAMINEhonoursQRESYNClikeSELECT(a read-only reopen with the last MODSEQ receivesVANISHED (EARLIER)and the changed flags), the taggedOKofSELECTsaysREAD-WRITE,BODYSTRUCTUREnames attachments and tells the truth about their parameters, and a synchronising orLITERAL+literal is accepted in any command (SEARCH SUBJECT {6}used to get a bareBADand no continuation), a refused literal being consumed so the session stays in sync. - Shared mailboxes appear in IMAP clients. A mailbox a colleague shares from the webmail is served under the two RFC 2342 namespaces the server declares:
Other Users/<owner address>/…for a human account’s mailbox shared with the session,Shared/<address>/…for a team account’s;CAPABILITYthen addsACLandRIGHTS=texk,MYRIGHTSderives from the grant, and a personal mailbox whose path coincides with a prefix wins for every verb.CREATE,DELETEandRENAMEunder a prefix are refused, an unshared or non-existent mailbox isNonExistentin every case (never an oracle over the directory), and the owner segment is matched against the session’s owner set, never looked up as an address. The face is switched per instance with[legacy] imap_shared_namespaces = "off" | "canary" | "on"(canaryrestricts it to the addresses inimap_shared_namespaces_accounts), overridable per tenant at runtime withoximail config set-override legacy.imap_shared_namespaces …and read atLOGIN;[rights] enforce = falsewins over any value. With the face off, the ACL verbs are refused rather than acknowledged empty (aDELETEACLused to answerOKwhile removing nothing), and\Deletedon a shared mailbox requires the delete right, since the owner will purge what a grantee flagged. - IMAP4rev1 clients see what is new.
\Recentis real for an IMAP4rev1 session: itsSELECTtakes for itself the messages no session has opened yet, a message being recent for one session only, and mail arriving while the mailbox is open goes to the first session that sees it.EXAMINEshows them without taking them, and an IMAP4rev2 session takes nothing.RECENTappears inSELECT,NOOP,IDLE,APPENDandSTATUS,\RecentinFETCHandSTOREanswers,SEARCH RECENT,NEWandOLDwork, andSTORE \RecentisBAD;SELECTsendsOK [UNSEEN n]. Existing mailboxes started at theirUIDNEXT. Amessage/globalpart (RFC 6532) is written in the IMAP4rev1 grammar, which knows onlymessage/rfc822as an encapsulated message, so such a session gets it as a basic body type; an IMAP4rev2 session gets the encapsulated form with its inner envelope, sections descend into it, and search reads its text. \Draftis a permanent flag.STOREsets and clears it (it answeredNO, and the command’s other flags were lost with it), andPERMANENTFLAGSlists it. It is the same stored$draftkeyword JMAP reads, soSEARCH DRAFT,Email/getandEmail/queryagree. It never turns a message into an editable draft: only the Drafts folder and the server’s own state changes do, so anAPPEND \Draftto the Inbox, or a rule adding$draftat delivery, no longer makes a received message editable and sendable.LISTandLSUBfollow LIST-EXTENDED (RFC 5258).LISTtakes a list of patterns, an unknownRETURNoption isBAD,RECURSIVEMATCHreturns the parent withCHILDINFO, andLSUBwith%returns the level of a subscribed child as\Noselect. A mailbox deleted byDELETEkeeps its subscription (RFC 3501 §6.3.6):LIST (SUBSCRIBED)shows it\NonExistent,UNSUBSCRIBEremoves it, and a new mailbox of that name takes it back.CREATEno longer subscribes the mailbox it creates (existing subscriptions are kept). On the shared namespaces,LIST (SUBSCRIBED)andLSUBlist the viewer’s own subscriptions only.SEARCH BODYandSEARCH TEXTgo through the full-text index, as the webmail’s search does, with the same conditions:BODYin the body alone,TEXTin the subject, theFrom,ToandCcaddresses and the body. A word past the first 256 characters of the body used to be missed by Thunderbird, Apple Mail or Outlook.SORTandTHREADselect their messages with the sameSEARCHparser and evaluator. Without an answer from the index, the preview answers and the log says why.LARGERat its upper limit matches nothing (it wrapped around and matched every message), andSEARCH FROM,TO,CCandBCCalso match a group’s name;BCCused to match nothing.SORTandTHREADfollow RFC 5256 and RFC 5957.SORTused to hand its keys to the JMAP sort: whole subject, whole address, no case mapping,CCnot sortable,DATEcompared as text across time zones. The keys are now the base subject, the mailbox part of the address (or the display name forDISPLAYFROMandDISPLAYTO), compared with the RFC collations, ties in mailbox order; a group sorts by its name, and an unknown key isBAD.THREAD=REFERENCESlets a message’s ownReferencesreplace the parent another message gave it, andTHREAD=ORDEREDSUBJECTgroups base subjects whatever their case. AnAPPENDkeeps the date it carries when it has no flag list, and every message of aMULTIAPPENDkeeps its own; both used to arrive dated “now”.- Addresses as the header wrote them.
ENVELOPEwrites address groups with their markers (RFC 3501 §7.4.2), an address without a domain keeps its local part, an address written with comments reads as the address alone, and an obsolete source route is kept as theENVELOPEroute. Messages stored before this are read again only when re-parsed from their raw file (oximail email reparse-broken,oximail repair-address-headers). - Another session’s changes arrive.
NOOPandCHECKannounce the flags another session changed, andEXPUNGEwhat a sibling expunged;FLAGSlists the keywords in use in the mailbox, and a new keyword is announced by* FLAGSbefore theFETCHthat shows it.MOVEinto the selected mailbox gives the message a new UID (RFC 6851). The first command that enables CONDSTORE after a plainSELECTgets* OK [HIGHESTMODSEQ n], andESEARCH RETURN (MIN)or(MAX)reports the MODSEQ of the message it returns (RFC 7162 §3.1.5).SELECT,EXAMINEorSTATUSon an ASCII name that is not valid modified UTF-7 answersNO [NONEXISTENT], where aBADmade clients drop the connection. - The mail ports speak only mail. Ports 993, 143, 465, 25 and 587 share the web server’s certificate, and they accepted a TLS handshake offering ALPN
http/1.1, so a browser could be made to talk to them (the ALPACA attack, RFC 9325 §3.8). They now accept only their own protocol (imap;smtpandesmtp) and refuse any other offer withno_application_protocol(RFC 7301 §3.2); a client that offers no ALPN, which is every mail client in use, is served as before. Before authentication, the fourth unparseable line or unknown command ends the session (IMAPBYE, SMTP421), so the header lines of a smuggled request cannot get a line of its body answered; a known command in the wrong state is still onlyBAD. On port 143, IMAP no longer announcesAUTH=PLAIN SASL-IRbeforeSTARTTLS, whereLOGINandAUTHENTICATEare refused anyway and a SASL-IR client would put its credentials on its first line in clear, and sends no second greeting after the handshake. - A suspended account stops reading. The account state is re-read on an open IMAP session, so a suspension closes the door on a connection that predates it, instead of serving the mailbox until the idle timeout.
- The journal no longer records how fast a client reads. The three lines per opened message (command, transfer start, transfer end) that made up 95 % of the journal on a box with an active client are gone; the session summary carries what an operator needs.
CalDAV (RFC 4791) and CardDAV (RFC 6352)
Calendars, task lists, and address books are served over HTTPS on the main listener, under the configured path prefixes. The bridges translate to the same JMAP objects the webmail edits, and every mutation is change-logged, so JMAP/changes, RFC 6578 sync-collection, and push all see a DAV write immediately.
Interop behaviours verified against real clients (Apple Calendar, Thunderbird, eM Client 10.4):
- Bootstrap at the server root. Some clients take only the hostname from autodiscovery and probe
OPTIONS/PROPFINDon/. The root advertises the combined DAV compliance classes and answers an authenticatedPROPFINDwithcurrent-user-principal; each principal cross-advertises the other service’s home set, so one discovery pass finds calendars, task lists, and address books. - Strict-parser-safe XML. Text payloads that embed HTML or a bare
&are CDATA-wrapped, so one decorated event description cannot make a whole multiget unparseable and kill a sync. - ETags are content hashes. A VEVENT’s ETag changes exactly when its representation changes — including when a recurrence override is added from another client — so a stale
If-Matchfails with412and the client resyncs instead of silently clobbering the change it never saw. Same for VTODO. - Server-side filtering is real.
calendar-queryevaluatestime-range(recurrence-aware, overlap semantics),prop-filter,param-filter, andtext-match; a filter shape the server does not model degrades to over-inclusive, never to a silently empty result.free-busy-queryreturns a properVFREEBUSY. - Time zones round-trip.
DTSTART;TZID=is emitted with the matchingVTIMEZONEcomponent (generated from the IANA database, 597 zones), Windows registry zone names from Outlook/Exchange are normalized to IANA on every ingest path, and Outlook-style parameterised properties (SUMMARY;LANGUAGE=fr-FR:…) parse correctly. - Client-created collections.
MKCALENDARat a client-chosen URL works (Apple Calendar and Thunderbird create collections at a UUID URL of their choosing). - Alarms are modelled. VEVENT and VTODO
VALARMcomponents import into the alert model and re-emit on GET, including absolute (VALUE=DATE-TIME) triggers. Public ICS feeds deliberately carry no VALARMs — a subscriber must not inherit the publisher’s reminders. - A report the server cannot name is refused. A
REPORTwhose body does not parse, names an unknown report or is empty used to receive a207containing the whole collection (896 events and 451 cards measured, identical answers in all three cases), so a client whose filter carried a typo synced everything, silently. A malformed body is400(RFC 4918 §8.2) and an unimplemented report403with thesupported-reportprecondition (RFC 3253 §3.6), never a multistatus. A URL segment that is not an account is not a principal (PROPFIND /caldav/not-a-uuid/used to answer207with a root collection named after the URL), and theDAV:header announces1, 3withcalendar-access,addressbookandsync-collection, no longer class2, which promised aLOCKandUNLOCKnobody served. - Shared calendars, and only what they show. A calendar shared to another account of the same server is reachable over CalDAV with the owner’s behaviour unchanged, a read-only share announces itself read-only, and the confidentiality of a shared calendar applies on every CalDAV rendering site,
REPORT,GET .icsandsync-collectionincluded, with the same reductionCalendarEvent/getapplies.VFREEBUSYis computed by the same function asPrincipal/getAvailability, where it used to carry a hand-written copy of it. - Renaming and recolouring from the client.
PROPPATCH(RFC 4918 §9.2) on a calendar or a task list setsdisplayname,calendar-description,calendar-color,calendar-orderand, on a calendar, its zone (calendar-timezone,calendar-timezone-id). The patch is atomic, with one status per property:403for a protected property,409for a bad value,424for the others of a patch that failed. It goes through the same write asCalendar/setandTaskList/set, so the change is logged and pushed to the other devices. An address book answers403to every property;AllownamesPROPPATCHwherever it is served. - A query answers what it asked. The component of a
calendar-querydecides: aVTODOfilter on an event calendar answers nothing, and a nestedVALARMfilter no longer applies to the event itself. A collation is honoured, or refused withsupported-collation; a malformed filter or time bound isvalid-filter.sync-collectionhonoursDAV:limit(an incremental answer is cut and continued with507, an initial one over the limit is refused withnumber-of-matches-within-limits), refuses a token from the future, and answers a store failure with500instead of a full resync.If-MatchandIf-None-Matchtake lists with strong or weak comparison, aGETcan answer304, andDepth: infinityis refused withpropfind-finite-depth. A query bounded to one week reads that week, not the whole calendar (2.1 ms instead of 71 ms over 10 000 events). - What a client saved comes back. Every property outside the modelled fields (
URL,GEO,X-properties) is served back as saved, andLOCATION,CATEGORIESandCOLORare modelled; the vCard lines the conversion does not model are kept, in order. APUTanswers theETagaGETwill serve. A missing calendar answers404toPROPFINDandREPORTand409to aPUTinto it, a UID already used in another calendar isno-uid-conflict, aVTODOin an event calendarsupported-calendar-component. - A deletion reaches the other devices. A single event, task or card deleted over CalDAV, CardDAV or JMAP is announced to
sync-collectionunder the href the clients know; it used to be announced under an internal name no client recognised, and stayed on the other devices. A destroyed object the server cannot name answersvalid-sync-token(a full resync) rather than a false href. - A series without an end lasts. A rule without
COUNTused to stop expanding one year after its first occurrence, and the series left period queries, free-busy and alarms. It is now walked from the requested window. A removed occurrence (an exclusion, a cancellation, anEXDATE) is no longer busy, alarmed or matched by atime-range. - Any XML prefix. DAV bodies are read by namespace, so a
sync-tokenor anMKCALENDARunder another prefix thanD:is understood; aDOCTYPEis refused, and a malformed sync orMKCALENDARbody is400.
Autodiscovery endpoints
Four endpoint families configure clients automatically (backed by the DNS records from the email-auth page):
These endpoints are anonymous and pre-fill the address a client asks about (
email= on the profile, emailaddress on the autoconfig, <EMailAddress> in the Autodiscover body). A value that is not an address (whitespace, a control character, <, > or ") is refused with 400, and every value written into the document, the organization’s name included, is escaped. A crafted link to the real server could otherwise serve, from the organization’s own host, a profile naming another mail server. An address with ' or & (o'brien@…) is accepted and escaped.
Behind a reverse proxy, proxy everything to OxiMail except the ACME challenge path — the wizard’s generated snippet does exactly that. A route allowlist or a proxy-level
OPTIONS/CORS interception silently breaks discovery and DAV; see First boot.Password attempts: slowed per account, never locked
Five doors take an address and a plaintext secret: IMAP, SMTPAUTH, ManageSieve, CalDAV/CardDAV with Basic, and POST /auth/login. They share one check, which tries the account’s app passwords first and the account password second, and one failure counter per account, kept in the database.
The first 5 failures cost nothing. After that no attempt is checked before a delay that doubles with each failure, from 1 second up to 60 seconds; a failure older than 24 hours no longer counts, and a success resets the counter. A refusal during the delay looks exactly like a wrong password, so it reveals nothing. A guessing attack spread over many addresses is held to at most one attempt a minute per account, where the per-address ban of fail2ban never saw it. The account’s owner is never locked out: a lockout would be a weapon against them. These are fixed values, not settings.
Not slowed and not counted: an address in [security] trusted_ips, a client whose secret was verified in the last 5 minutes (it holds the right secret), and a passkey sign-in. The per-address fail2ban bans and the limit on /auth/login stay as they were. A password change, an app-password revocation or a frozen account takes effect on the next request, not when the 5 minutes end.
What legacy mode does not change
The bridges are read/write access paths, not a parallel server: authentication, the blob store, tenant isolation, fail2ban, and rate limiting are the same machinery as JMAP. Disabling[legacy] removes the listeners and route prefixes; the data is untouched.