Skip to content

Webmail

Purpose. A mailbox owner reads, searches, organises and sends mail in the browser at https://webmail.<your own domain> — the visible half of the product, and the client the AI features plug into. http://webmail.<your own domain> answers a redirect to https://, never the page itself, so a password is not sent unencrypted.

It is its own application. The webmail and the admin panel are two builds with two bundles (apps/webmail and apps/admin), sharing only packages/web-shared. No admin screen is in this bundle, this client has its own sign-in, and the installable app and its offline cache are scoped to it rather than to the whole origin. An administrator (owner, admin or domain_admin) signed in here has Administration, with a shield, in the account menu, and it leaves for the console; that is a full page load, because it is a different application.

Where it lives. https://webmail.<your own domain> — one host for the whole server, derived from the hostname setup was completed on, exactly as the admin panel’s postmaster. is. Every mailbox signs in there whichever of the server’s domains it belongs to. Publish it as an A record at the host (or a CNAME to the mail hostname). With automatic TLS (ACME), Caddy issues its certificate on first request after the API vets the name; with a certificate of your own (BYO) that certificate is the only one served on the name, so it must cover webmail.<your own domain> (Bring your own TLS certificate).

It serves this client and nothing else. Until 2026-09-12 a webmail. host served the same bundle as the mail hostname, so the admin console was reachable at webmail.<domain>/domains/{id}. The console has a name of its own now, postmaster.<your own domain>.

https://<hostname>/mail/…, where this client used to live, redirects to the webmail host with the /mail prefix stripped — /mail/INBOX becomes /INBOX there (packages/engine-postfix-dovecot/src/caddy.ts).

It was one host per hosted domain until 2026-09-12, so that each domain could hand its users an address that looked like theirs. An install is one organisation — v1 creates exactly one tenant — so those were second and third names for the same organisation’s own staff — and each was a separate browser origin, which means a separate sign-in for anyone whose mailboxes were not all in one domain. The branding was worth less than the second login, so there is one name. webmail.<some other domain this server hosts> no longer routes to anything.

A lapsed licence. From the server’s licence degraded stage on, a notice under the header says so and what it costs the reader: AI features are off, and from send_suspended the mail they send is refused while receiving and reading continue. It cannot be dismissed, because it stays true until the administrator renews (MailAccount.licenseNotice). A message to recipients both on this server and outside it, sent during send_suspended, reaches the first: the composer stays open with only the refused recipients left in To and the server’s reply, so pressing Send again cannot reach the others twice (rejected on POST /mail/send).

Somebody else’s mailbox. An owner or admin can open any mailbox from the admin panel and use this client as its owner does — reading, and composing and sending as that address, which recipients cannot distinguish from the owner’s own mail. While that is happening the client shows an undismissable banner naming the mailbox and the administrator holding it, and offering the way back to the console. The session is the mailbox’s: every admin route refuses it. It lasts an hour, it is opened by a single-use link that expires in thirty seconds, and both the opening and every message sent are in the audit log. The mailbox owner is not notified.

Behaviour.

  • Sign-in: this client’s own sign-in page accepts mailbox credentials (alice@example.com + mailbox password) and lands on the inbox. An administrator does not share a session with it — the two applications are separate origins — so the admin panel hands them across instead: the account menu’s Webmail for the mailbox at their own address, a mailbox row’s ⋯ menu for anyone’s.
  • Folders (GET /mail/folders): Dovecot’s special-use folders (Inbox, Drafts, Sent, Archive, Junk, Trash) plus the user’s own, with message/unseen counts; + New folder creates one (POST /mail/folders), and says why when the server refuses.
  • Folders are a tree, nested by the server’s own delimiter (. on Dovecot’s Maildir++, / elsewhere), each with a fold arrow. A folder of your own has a menu: Rename (PUT /mail/folders, in place), New folder inside, and Delete folder (DELETE /mail/folders), which asks first and deletes the folders inside it too, deepest first, because Dovecot refuses to delete a folder that has folders inside. A message dragged from the list onto a folder moves there, with the same toast and Undo as Move to. Trash and Junk have Empty (POST /mail/folders/empty), which asks first and deletes every message in it for good. The server refuses it for any other folder, by the folder’s role, so a wrong path cannot empty an Inbox.
  • Message list (GET /mail/messages?folder=&limit=&before=): newest first, infinite scroll (next = the uid to continue from); filters q (from/to/subject/body via IMAP SEARCH, body served from the Dovecot fts_flatcurve index), unseen, flagged, attachments, since/until, keyword (a label), pinned ($Pinned; with keyword as well, both must hold) and category (one of the Inbox’s category tabs, Inbox categories); folder=* searches every folder but Trash and Junk (below). Each row carries flags, size, hasAttachments, the threading heads — messageId, inReplyTo and the full references chain (the list fetch asks IMAP for the References: header, which ENVELOPE omits) — and preview, the start of the body as text. The preview comes from IMAP PREVIEW (RFC 8970), which Dovecot computes and caches: one UID FETCH … (PREVIEW) for the page, sent only when the server advertises it, and a failure leaves the rows without one rather than failing the list.
  • The header holds the search, a button for the command palette (Jump to…, ⌘K), Settings and an account menu: the address, the theme (Light, Dark, System), Settings, the admin panel for an administrator, and Sign out, which asks first. New message heads the folder column instead, with its key beside it. The tab’s title starts with the Inbox’s unread count, as in (3) MailDock.
  • Search covers every folder but Trash and Junk. The header’s box goes to /search?q=, whose list asks GET /mail/messages?folder=*&q=; the bridge searches each folder and merges the matches newest first, as one page. Each result keeps its own folder, and opens beside the results at /search/<folder>/<uid>, so Back returns to them. Copies of one message in two folders, the Inbox’s and Sent’s, group as one conversation. There is no bulk selection over search results, since the bulk actions act on one folder. A folder named exactly search, like one named compose or scheduled (the Scheduled list’s route, Compose, send and drafts), loses its own message URLs to the route. ?q= on a folder’s own URL still searches that folder alone. Until 2026-10-19 search was a box over the list and covered only the folder in view.
  • A row shows an avatar (the sender’s profile picture when the sender is a mailbox here that set one, User settings (webmail); initials otherwise), the sender, the subject and the preview line, with the attachment clip and labels. Unread is a dot as well as weight, and the dot says Unread to a screen reader, where weight alone said nothing. Pinned and Important show as icons by the date. On hover, or with focus inside the row, the date gives way to its own actions: Archive, Delete, Mark unread and Important. An action that takes away the open message closes it. Compact density drops the avatar and the preview. An empty list says why: a search that matched nothing names the search, and says search covers every folder but Trash and Junk from the header’s search, or one folder at a time from a folder’s own ?q=; a filter, a label and an empty folder each say so in their own words. A list still loading shows grey rows in its shape rather than a spinner.
  • The open message has its own URL, /<folder>/<uid>. A link or a reload opens it, and Back closes it. Moving from one message to the next replaces the entry rather than adding one, so Back returns to the list rather than walking back through every message read on the way. Archiving, deleting or marking it unread closes it the same way. A draft is the exception: it opens in the composer and never gets a URL.
  • The panes resize. A splitter between the list and the reading pane drags, and it is a keyboard control too: focus it, then the arrow keys move it 16 px and Home and End take it to its limits. Its size is saved on the mailbox a moment after it stops moving (listWidth, or listHeight with the pane below the list), so it is the same in every browser the mailbox is opened in. Settings → General puts the reading pane beside the list, below it, or off, and sets the list’s density.
  • On a phone (below 768 px) the list and the message take the screen in turn. The folders, labels included, and Settings are a drawer behind the menu button beside the folder’s name. New message is a floating button over the list, and an open composer is the whole screen. The message has a back arrow, as well as the browser’s Back. Until 2026-10-19 the reading pane was simply hidden on a phone, so tapping a message only marked it read.
  • The reader is the conversation. Its subject comes first, then its messages oldest first: the earlier ones folded to a line (who, the preview, when) that opens with a click, and the one opened from the list and the newest open. Reply, Reply all and Forward sit under the conversation and answer its newest message. The toolbar is icons, each with its name and key as a tooltip: Archive, Delete and Mark unread act on the whole conversation; Important, Pin and Label act on the message opened. More holds Move to (every folder), Move to category in the Inbox while its tabs are on (Inbox categories), Report spam (to the Junk folder), Filter messages like this (the filter editor opened on a new filter filled in from the message: its list’s List-Id if it came from one, otherwise its sender, and its subject without Re:; /settings/filters?like=<uid>&in=<folder>, and Try it shows the message among those it matches), Print (the message alone, in its own window, with remote images still blocked) and Show original. The arrows at the right, like k and j, go to the newer and older conversation, and archiving or deleting moves on to the next one, as e and # do. Show original reads GET /mail/messages/{uid}/raw?folder=, the message as stored, served as text/plain with nosniff and a sandbox CSP, and shows it as text. The conversation is the list’s own grouping (Conversations above), so a message opened by its URL before the list holds its page shows alone. The body follows dark mode: in a dark theme the message frame is drawn dark wherever the message sets no colours of its own. One that paints its own background keeps it, as in other clients’ dark modes.
  • Reading (GET /mail/messages/{uid}?folder=): text + HTML bodies, addresses, references, attachment list; HTML is sanitised (DOMPurify) and rendered in a sandboxed iframe with remote images blocked until the user allows them; inline images and attachments stream from /mail/messages/{uid}/parts/{part} (nosniff, CSP sandbox). The reading pane reads both through the API client, so every part request carries the session. An attachment is fetched when it is clicked and saved from a blob: URL. When an HTML message opens, each inline part — one with a Content-ID, up to 5 MB — is fetched before the body renders and embedded as a data: URL, but only when the part route serves it as one of the image types listed below (anything else, or a cid: with no matching part, is dropped) — because the body’s iframe is sandboxed without allow-same-origin and cannot be relied on to load a blob: URL the page created. An inline part that cannot be read is dropped from the body, never linked bare. Before 2026-09-13 both were plain <a href>/<img src> requests, which carry no Authorization header, and answered 401 for every user. The part route serves its own content-type only for an explicit allowlist — image/png, image/jpeg, image/gif, image/webp, application/pdf and text/plain — and everything else is rewritten to application/octet-stream and forced to download. The allowlist is the whole defence: a part’s declared type comes from the sender, so anything not on it would be a type the browser might execute in our origin.
  • Conversations: the list groups messages into threads client-side over the pages already loaded — a union-find over Message-ID/In-Reply-To/References, with a normalised-subject fallback (Re:/Fwd:/list tags stripped) that only links when one side is a reply, so two unrelated messages titled “Invoice” stay apart. A conversation row shows the participants oldest-first, the message count, the newest date, and is unread/flagged/attachment-bearing if any message in it is; the chevron expands it to the individual messages. Grouping is per-mailbox (a threaded key inside mailbox_settings.settings, the jsonb column — not a column of its own; User settings (webmail) describes the shape) and defaults on. It toggles from the icon at the right of the filter bar or from Settings.
  • Every action says what it did. Archive, delete, move and report as spam change the list at once, before the server answers, and a toast says what was done, with Undo. Undo moves exactly those messages back: POST /mail/messages/move and /mail/messages/delete answer with where the messages went, the folder and their new uids from the server’s UIDPLUS COPYUID (MovedTo). If the server refuses, the list is put back as it was and a toast gives the server’s own reason; until then every mail-action failure was swallowed. A delete in Trash or Junk removes the messages for good, so it asks first, says Deleted for good and has no Undo. The reader, e and # move on to the next conversation as soon as the action is certain, so a refusal leaves the message in its folder with the reader already on the next one. A flag change that fails is put back and said too.
  • Actions: flags (PUT /mail/messages/flags — seen/flagged/answered/keywords), move (POST /mail/messages/move), delete (POST /mail/messages/delete: to Trash, permanent inside Trash/Junk or with permanent: true), bulk on up to 1000 uids.
  • Labels are IMAP keywords with a name and a colour (labels in mailbox_settings.settings, at most 15). They are made, renamed, recoloured and deleted in the sidebar’s Labels section. Each one filters the current folder to the mail carrying it (GET /mail/messages?keyword=, IMAP SEARCH KEYWORD), shown as a chip in the filter bar that clears it. A label is applied from the open message’s Label menu (l opens it) or from the bulk bar, through the flags route, and rows show each label as a coloured chip. The command palette lists every label as a command on the open message too: Add label: Work, or Remove label: Work when the message already carries it. The keyword is derived from the name when the label is made (lower-cased, IMAP-safe, never $… or \…) and kept through renames, so Thunderbird and other IMAP clients see the same keyword on the same mail. Deleting a label forgets its name and colour; the keyword stays on the messages.
  • Bulk selection: a checkbox per row (ticking a conversation ticks every message in it), shift-click for a range, x for the row under the cursor, and select-all over the loaded pages. The toolbar that replaces the filter bar marks read/unread, stars/unstars, archives, moves to any folder and deletes; a selection over 1000 uids is split into several requests rather than rejected.
  • Important and pinned. There is one flag, Important: IMAP \Flagged, the flag every client and phone already shows and syncs, drawn as a flag. The star it replaced meant the same thing, and two would have been one too many. Set it with s, the flag at the end of a row (shown on hover, and always once set), the reader’s toolbar or the bulk bar. important is a filter chip over the list, and a filter’s Mark as important action sets it. Pin a message with p or the reader’s Pin, and it stays in a Pinned group at the top of its folder until it is unpinned. A pin is the IMAP keyword $Pinned, stored like a label’s, so it follows the message to every device. The group is shown over the whole folder in date order, and turned off under Settings → General (Keep pinned messages at the top of their folder). A search, a filter or a label shows pinned mail where it falls.
  • Keyboard and the command palette: in the mail views, c new message, / the header’s search, j/k next and previous message (and on the last row loaded, the next page) (over the whole list, not just the rows on screen; until 2026-10-19 they stopped at the edge of the rows the virtualised list had drawn), and on the open message r reply, a reply all, f forward, s important, p pin, e archive, # delete, u mark unread, l labels and x select it for a bulk action. None of them fires while typing in a field, nor while focus is anywhere inside a composer, its buttons included. ⌘K (Ctrl+K), or ? outside a field, opens the command palette: everything those keys do, plus going to any folder, any Settings section, the theme and signing out, filtered as you type (every word must match) and run with Enter. It lists only what applies now — the message actions only while a message is open — and shows each one’s key beside it, which is how the keys are discovered. It works on Settings too, without New message.
  • Contacts (GET /mail/contacts?q=&limit=, limit default 10 and at most 50, q at most 100 characters): everyone the mailbox has written to, read from the Sent folder’s envelopes and ranked by how often — the composer’s recipient autocomplete. q matches the address or the display name.
  • Push (GET /mail/events, SSE): exists (new mail), expunge, flags from the pooled IMAP session’s IDLE; the client refreshes the affected folder. The events say only that something changed, never what: the tab fetches the change through ordinary requests. The stream is authorised again before every event and at every 25-second heartbeat, so it lives no longer than the access token that opened it. When it is refused it sends a final end event naming why (unauthorized, no_mailbox or error) and closes. The client does not wait for that in normal use: when the session refreshes its token, a minute before expiry, it opens a stream with the new token and closes the old one once the new one is up, so no event is missed. Any other end is reopened with the current token, backing off from 1 s to a minute (with jitter) while it is refused, and the reopen refetches the folder list and messages, because events sent while closed are lost. Signing out closes the stream at once. A stream checks at most once at a time, shared between the events and the heartbeat, and an event goes out only on a check that began after it arrived. Events that arrive during a check wait for its verdict, up to 100, and the rest are dropped, since they only say that something changed.
  • Account (GET /mail/account, PUT /mail/account/settings, PUT /mail/account/password, and the profile: PUT /mail/account/profile for the display name, PUT/DELETE /mail/account/avatar for the picture, and POST /mail/avatars/lookup and GET /mail/avatars/{address} to read other mailboxes’ pictures, see User settings (webmail)): address, quota usage, signature, display preferences, undo-send window; password change requires the current password and ends every session of the mailbox, administrators’ on-behalf ones included; the mailbox’s own caller is handed a new one, an administrator acting on its behalf is not, and every message waiting to be sent later is held. Filters/autoresponder for the signed-in mailbox: see Mailbox filters & autoresponders (/mail/filters, /mail/autoresponder).
  • Compose / send / drafts: see Compose, send and drafts.
  • Install & offline reading: see Installable webmail & offline reading — the client installs as an app and keeps already-read mail readable without a network.

Configuration. DOVECOT_MASTER_PASSWORD (already required for filters); IMAP_PORT/SERVICE_DOVECOT/SUBMISSION_PORT/SERVICE_POSTFIX for the bridge inside the compose network (defaults are the compose service names and ports 143/587). compose/compose.yaml passes none of them to api, so the defaults are what runs — and setting IMAP_PORT or SUBMISSION_PORT in .env changes the ports Dovecot and Postfix are published on, not the bridge. Both hops upgrade with STARTTLS whenever the daemon offers it, which Dovecot and Postfix do exactly when tls.mode is not none — and then they refuse a plain login. There is no setting for it. Until that was fixed, the IMAP bridge never upgraded, so on every install with TLS the webmail could not log in at all (Login is disabled, [PRIVACYREQUIRED] in Dovecot’s log). MAIL_BRIDGE=noop disables IMAP access (tests, OpenAPI export) — the routes answer 503 mail_bridge_unavailable.

Limits & edge cases.

  • One pooled IMAP connection per signed-in mailbox, closed 10 minutes after its last request once no tab holds the event stream open; the first request after that reconnects (a few hundred ms). Each list issues a NOOP first so the pooled session sees mail delivered since its last command — without it a just-delivered message could be missing from the next list for a few seconds.
  • Search is IMAP SEARCH, with body/text queries served from the fts_flatcurve index. Indexed search is word-based, so a substring that the old brute-force scan would have matched no longer matches. Attachment contents are not indexed (that needs a decoder).
  • A mailbox with no index yet — mail that predates the feature, or a mailctl restore — still searches correctly but unindexed and slowly (fts_enforced = no), until fts_autoindex or doveadm index fills it. See Webmail search and list performance.
  • Threading is client-side and therefore page-scoped: a reply groups with a parent only while both are among the messages loaded so far. IMAP THREAD is not used: the bridge would have to hold the whole folder.
  • Labels are capped at 15 because Dovecot’s Maildir stores a folder’s keywords as the letters a–z — 26 per folder, shared with the webmail’s own ($Pinned, and the four inbox categories) and those other clients set ($Forwarded, $Junk, NonJunk). The cap was 20 until 2026-10-19. A mailbox that already held 16 to 20 keeps them, and PUT /mail/account/settings refuses adding another with 400 too_many_labels while it holds 15 or more. A label filter, like search, covers one folder at a time. Most clients other than Thunderbird do not show keywords at all; Apple Mail and Outlook ignore them.
  • Select-all covers the loaded pages, not the whole folder — the count in the toolbar is always exactly what will be acted on.
  • Contacts come from the most recent 500 Sent messages (envelopes only, no bodies), cached per mailbox for 10 minutes and dropped immediately after a send so a new recipient is offered straight away. Someone who has only ever received mail from you outside that window is not suggested; there is no address book.
  • A folder that no longer exists — deleted in another client, or renamed while a tab was open — answers 404 not_found with the message folder not found, so the client can drop it and reload the folder list rather than treating it as a server fault. (The code is not_found, which is not unique to this case; match on it together with the 404.) Any other IMAP refusal answers 502 imap_error carrying the mail server’s own words (“Quota exceeded”, “Permission denied”), not a generic failure message.
  • An account with no active mailbox — an administrator whose login address is not a mailbox, or a mailbox that is inactive or suspended — gets 403 no_mailbox from every /mail/* route, a code of its own rather than forbidden. The webmail branches on it and shows one explanation, This account has no mailbox, in place of the whole mail view; the header shows no address, and New message and Settings are hidden. Every other failure of GET /mail/folders is shown in the folder sidebar with its reason and a Try again button, and the sidebar tells loading, failed and empty apart. The webmail retries a failed query once, but never a 4xx other than 408 and 429 (src/lib/query-client.ts): a retried no_mailbox held those error states on screen for a second before the explanation. The mailbox-scoped /ai/* routes answer 403 no_mailbox for such an account too. Until that was fixed, both cases rendered as an empty inbox beside a raw error.
  • Any other failure of GET /mail/account — the API up but the database not answering, say — leaves the mail views working on defaults, and a banner above the header says which: new messages are drafted without the signature, and the list uses the default threading and page size. It has a Try again button. The composer repeats it under the body when it drafted without the signature, and reply-all still leaves the user’s own address out, taken from the session instead of the account. A failed GET /mail/contacts shows Suggestions are unavailable under the recipient field once something is typed, rather than offering nothing. Until 2026-10-01 all three fell back silently, and reply-all kept the user’s own address in Cc.
  • system mailboxes (DMARC ingestion) cannot sign in.
  • Suspending or deactivating a mailbox ends all its webmail sessions at once, administrators’ on-behalf ones included, and an access token already issued is refused on its next request, because every request re-reads the mailbox row. An open tab’s event stream is refused at its next event or heartbeat, whichever comes first, and the mailbox’s pooled IMAP session is closed then rather than at the idle sweep, unless another tab’s stream or a request in flight still holds it, or the address now belongs to a mailbox created again under it. The refusal is polled, not pushed, so it takes up to 25 seconds. After a password reset the stream ends at its next event or heartbeat, as every other request is refused: the reset ended its session.