Skip to main content
OxiMail is JMAP-first, but real deployments have Thunderbird, Apple Mail, eM Client, and phone clients that speak the classic protocols. The 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 send ENABLE 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 EXPUNGE lines (old-view numbering, descending), never as a bare decreasing EXISTS, which RFC 9051 forbids and which multi-connection clients like eM Client punish with a full folder resync.
  • Incremental resync. CONDSTORE and QRESYNC are supported, including VANISHED responses, 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 EXPUNGE honors its uid-set.
  • Honest identification. The ID command 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 SMTP AUTH lines 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: a DAV sign-in line 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 OK to everything; it now refuses what it does not understand (an unknown command, an unknown FETCH or STATUS item, a malformed search date, an unsupported charset, a zero sequence number, a missing mailbox in STATUS), answers BAD to a command outside its state (SEARCH before SELECT, STATUS before LOGIN), and honours the four capabilities it announced without holding them: THREAD=REFERENCES and THREAD=ORDEREDSUBJECT build a real RFC 5256 tree from Message-ID, References and In-Reply-To instead of a flat sequence, SEARCH MODSEQ, STATUS (SIZE) and QUOTA/GETQUOTAROOT (RES-STORAGE, backed by the storage quota) do what they say. EXAMINE honours QRESYNC like SELECT (a read-only reopen with the last MODSEQ receives VANISHED (EARLIER) and the changed flags), the tagged OK of SELECT says READ-WRITE, BODYSTRUCTURE names attachments and tells the truth about their parameters, and a synchronising or LITERAL+ literal is accepted in any command (SEARCH SUBJECT {6} used to get a bare BAD and 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; CAPABILITY then adds ACL and RIGHTS=texk, MYRIGHTS derives from the grant, and a personal mailbox whose path coincides with a prefix wins for every verb. CREATE, DELETE and RENAME under a prefix are refused, an unshared or non-existent mailbox is NonExistent in 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" (canary restricts it to the addresses in imap_shared_namespaces_accounts), overridable per tenant at runtime with oximail config set-override legacy.imap_shared_namespaces … and read at LOGIN; [rights] enforce = false wins over any value. With the face off, the ACL verbs are refused rather than acknowledged empty (a DELETEACL used to answer OK while removing nothing), and \Deleted on a shared mailbox requires the delete right, since the owner will purge what a grantee flagged.
  • IMAP4rev1 clients see what is new. \Recent is real for an IMAP4rev1 session: its SELECT takes 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. EXAMINE shows them without taking them, and an IMAP4rev2 session takes nothing. RECENT appears in SELECT, NOOP, IDLE, APPEND and STATUS, \Recent in FETCH and STORE answers, SEARCH RECENT, NEW and OLD work, and STORE \Recent is BAD; SELECT sends OK [UNSEEN n]. Existing mailboxes started at their UIDNEXT. A message/global part (RFC 6532) is written in the IMAP4rev1 grammar, which knows only message/rfc822 as 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.
  • \Draft is a permanent flag. STORE sets and clears it (it answered NO, and the command’s other flags were lost with it), and PERMANENTFLAGS lists it. It is the same stored $draft keyword JMAP reads, so SEARCH DRAFT, Email/get and Email/query agree. It never turns a message into an editable draft: only the Drafts folder and the server’s own state changes do, so an APPEND \Draft to the Inbox, or a rule adding $draft at delivery, no longer makes a received message editable and sendable.
  • LIST and LSUB follow LIST-EXTENDED (RFC 5258). LIST takes a list of patterns, an unknown RETURN option is BAD, RECURSIVEMATCH returns the parent with CHILDINFO, and LSUB with % returns the level of a subscribed child as \Noselect. A mailbox deleted by DELETE keeps its subscription (RFC 3501 §6.3.6): LIST (SUBSCRIBED) shows it \NonExistent, UNSUBSCRIBE removes it, and a new mailbox of that name takes it back. CREATE no longer subscribes the mailbox it creates (existing subscriptions are kept). On the shared namespaces, LIST (SUBSCRIBED) and LSUB list the viewer’s own subscriptions only.
  • SEARCH BODY and SEARCH TEXT go through the full-text index, as the webmail’s search does, with the same conditions: BODY in the body alone, TEXT in the subject, the From, To and Cc addresses and the body. A word past the first 256 characters of the body used to be missed by Thunderbird, Apple Mail or Outlook. SORT and THREAD select their messages with the same SEARCH parser and evaluator. Without an answer from the index, the preview answers and the log says why. LARGER at its upper limit matches nothing (it wrapped around and matched every message), and SEARCH FROM, TO, CC and BCC also match a group’s name; BCC used to match nothing.
  • SORT and THREAD follow RFC 5256 and RFC 5957. SORT used to hand its keys to the JMAP sort: whole subject, whole address, no case mapping, CC not sortable, DATE compared as text across time zones. The keys are now the base subject, the mailbox part of the address (or the display name for DISPLAYFROM and DISPLAYTO), compared with the RFC collations, ties in mailbox order; a group sorts by its name, and an unknown key is BAD. THREAD=REFERENCES lets a message’s own References replace the parent another message gave it, and THREAD=ORDEREDSUBJECT groups base subjects whatever their case. An APPEND keeps the date it carries when it has no flag list, and every message of a MULTIAPPEND keeps its own; both used to arrive dated “now”.
  • Addresses as the header wrote them. ENVELOPE writes 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 the ENVELOPE route. 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. NOOP and CHECK announce the flags another session changed, and EXPUNGE what a sibling expunged; FLAGS lists the keywords in use in the mailbox, and a new keyword is announced by * FLAGS before the FETCH that shows it. MOVE into the selected mailbox gives the message a new UID (RFC 6851). The first command that enables CONDSTORE after a plain SELECT gets * OK [HIGHESTMODSEQ n], and ESEARCH RETURN (MIN) or (MAX) reports the MODSEQ of the message it returns (RFC 7162 §3.1.5). SELECT, EXAMINE or STATUS on an ASCII name that is not valid modified UTF-7 answers NO [NONEXISTENT], where a BAD made 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; smtp and esmtp) and refuse any other offer with no_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 (IMAP BYE, SMTP 421), 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 only BAD. On port 143, IMAP no longer announces AUTH=PLAIN SASL-IR before STARTTLS, where LOGIN and AUTHENTICATE are 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 / PROPFIND on /. The root advertises the combined DAV compliance classes and answers an authenticated PROPFIND with current-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-Match fails with 412 and the client resyncs instead of silently clobbering the change it never saw. Same for VTODO.
  • Server-side filtering is real. calendar-query evaluates time-range (recurrence-aware, overlap semantics), prop-filter, param-filter, and text-match; a filter shape the server does not model degrades to over-inclusive, never to a silently empty result. free-busy-query returns a proper VFREEBUSY.
  • Time zones round-trip. DTSTART;TZID= is emitted with the matching VTIMEZONE component (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. MKCALENDAR at a client-chosen URL works (Apple Calendar and Thunderbird create collections at a UUID URL of their choosing).
  • Alarms are modelled. VEVENT and VTODO VALARM components 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 REPORT whose body does not parse, names an unknown report or is empty used to receive a 207 containing 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 is 400 (RFC 4918 §8.2) and an unimplemented report 403 with the supported-report precondition (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 answer 207 with a root collection named after the URL), and the DAV: header announces 1, 3 with calendar-access, addressbook and sync-collection, no longer class 2, which promised a LOCK and UNLOCK nobody 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 .ics and sync-collection included, with the same reduction CalendarEvent/get applies. VFREEBUSY is computed by the same function as Principal/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 sets displayname, calendar-description, calendar-color, calendar-order and, on a calendar, its zone (calendar-timezone, calendar-timezone-id). The patch is atomic, with one status per property: 403 for a protected property, 409 for a bad value, 424 for the others of a patch that failed. It goes through the same write as Calendar/set and TaskList/set, so the change is logged and pushed to the other devices. An address book answers 403 to every property; Allow names PROPPATCH wherever it is served.
  • A query answers what it asked. The component of a calendar-query decides: a VTODO filter on an event calendar answers nothing, and a nested VALARM filter no longer applies to the event itself. A collation is honoured, or refused with supported-collation; a malformed filter or time bound is valid-filter. sync-collection honours DAV:limit (an incremental answer is cut and continued with 507, an initial one over the limit is refused with number-of-matches-within-limits), refuses a token from the future, and answers a store failure with 500 instead of a full resync. If-Match and If-None-Match take lists with strong or weak comparison, a GET can answer 304, and Depth: infinity is refused with propfind-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, and LOCATION, CATEGORIES and COLOR are modelled; the vCard lines the conversion does not model are kept, in order. A PUT answers the ETag a GET will serve. A missing calendar answers 404 to PROPFIND and REPORT and 409 to a PUT into it, a UID already used in another calendar is no-uid-conflict, a VTODO in an event calendar supported-calendar-component.
  • A deletion reaches the other devices. A single event, task or card deleted over CalDAV, CardDAV or JMAP is announced to sync-collection under 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 answers valid-sync-token (a full resync) rather than a false href.
  • A series without an end lasts. A rule without COUNT used 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, an EXDATE) is no longer busy, alarmed or matched by a time-range.
  • Any XML prefix. DAV bodies are read by namespace, so a sync-token or an MKCALENDAR under another prefix than D: is understood; a DOCTYPE is refused, and a malformed sync or MKCALENDAR body is 400.

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, SMTP AUTH, 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.