Webmail

Read, search, organise and send mail from the browser, with conversations, rules, identities and live updates.

In preview webmail version 1.0.0 Part of Mail and webmail

Actions

64 actions, callable from the panel, the command palette and the API as POST /api/v1/a/<id>. Internal actions used between modules are not listed.

ActionWhat it doesRiskPreview
webmail.session.open Sign in to a mailbox medium No dry run
webmail.session.sso Open webmail for a mailbox without its password medium No dry run
webmail.session.close Sign out of a mailbox low No dry run
webmail.session.list List open sessions (read) low No dry run
webmail.session.watch Choose the folder that sends live updates (read) low No dry run
webmail.folder.list List folders (read) low No dry run
webmail.folder.create Create a folder low No dry run
webmail.folder.rename Rename a folder low No dry run
webmail.folder.delete Delete a folder medium
webmail.folder.empty Empty Trash or Junk high
webmail.folder.subscribe Show or hide a folder low No dry run
webmail.message.list List messages (read) low No dry run
webmail.thread.list List conversations (read) low No dry run
webmail.message.search Search messages (read) low No dry run
webmail.message.get Read a message (read) low No dry run
webmail.message.source View a message's source (read) low No dry run
webmail.attachment.get Download an attachment (read) low No dry run
webmail.message.flags Change message flags low No dry run
webmail.message.move Move or copy messages low No dry run
webmail.message.delete Delete messages medium No dry run
webmail.message.archive Archive messages low No dry run
webmail.message.spam Report spam or not spam low No dry run
webmail.message.reply_template Prepare a reply or forward (read) low No dry run
webmail.draft.save Save a draft low No dry run
webmail.message.send Send a message medium No dry run
webmail.send.cancel Undo a send low No dry run
webmail.send.schedule Schedule a message medium
webmail.scheduled.list List scheduled messages (read) low No dry run
webmail.scheduled.edit Edit a scheduled message low
webmail.scheduled.cancel Cancel a scheduled message low
webmail.message.snooze Snooze messages low
webmail.snooze.list List snoozed messages (read) low No dry run
webmail.snooze.cancel Unsnooze a message low
webmail.upload.chunk Upload an attachment chunk low No dry run
webmail.upload.discard Discard an upload low No dry run
webmail.rules.get Read mail rules (read) low No dry run
webmail.rules.validate Check mail rules (read) low No dry run
webmail.rules.set Save mail rules medium No dry run
webmail.vacation.get Read the out-of-office reply (read) low No dry run
webmail.vacation.set Set the out-of-office reply medium No dry run
webmail.identity.list List identities (read) low No dry run
webmail.identity.set Save an identity low No dry run
webmail.identity.delete Delete an identity low No dry run
webmail.settings.get Read webmail settings (read) low No dry run
webmail.settings.set Save webmail settings low No dry run
webmail.sender.trust Always load images from a sender low No dry run
webmail.contacts.list List contacts (read) low No dry run
webmail.contacts.save Save a contact low
webmail.contacts.delete Delete a contact medium
webmail.contacts.import Import contacts from a vCard file medium
webmail.contacts.export Export contacts as a vCard file (read) low No dry run
webmail.contacts.group Add contacts to a group or take them out low
webmail.calendar.list List calendars (read) low No dry run
webmail.calendar.save Create or change a calendar low
webmail.calendar.delete Delete a calendar high
webmail.calendar.events List events in a window (read) low No dry run
webmail.calendar.event.get Read one event (read) low No dry run
webmail.calendar.event.save Save an event low
webmail.calendar.event.delete Delete an event medium
webmail.calendar.import Import events from an .ics file medium
webmail.calendar.export Export a calendar as an .ics file (read) low No dry run
webmail.calendar.invite.read Read an invitation in a message (read) low No dry run
webmail.calendar.invite.respond Answer an invitation medium
webmail.calendar.invite.apply Apply an update or cancellation medium

Permissions and limits

Permissions

  • webmail.session.use Sign in to a mailbox in webmail
  • webmail.session.sso Open webmail for a mailbox without its password
  • webmail.mail.read Read and search mail
  • webmail.mail.write Flag, move, archive and delete mail
  • webmail.mail.send Write and send mail
  • webmail.folder.manage Create, rename and delete folders
  • webmail.rules.manage Manage mail rules and the out-of-office reply
  • webmail.identity.manage Manage sending identities and signatures
  • webmail.contacts.use Use the contacts of a mailbox
  • webmail.calendar.use Use the calendars of a mailbox
  • webmail.settings.manage Manage webmail preferences

Plan limits

  • webmail.identities Sending identities per account

Engineering notes

Generated from modules/webmail/docs.md at build f3a3c074. These are the notes the engineers keep next to the code: precise, technical, and honest about what is not done yet.

The module is a plugin that sits between the browser (which cannot speak IMAP) and the mailbox's Dovecot/Postfix (ADR 0012 §1). It stores no mail and no mailbox password: one live IMAP connection per signed-in mailbox lives in the plugin process; the password is kept in memory for the life of the session (needed to reconnect and to authenticate SMTP/ManageSieve) and zeroed on close. Libraries: emersion go-imap v2, go-smtp, go-message, go-sasl (MIT, in THIRD_PARTY_NOTICES.md); the HTML sanitiser is our own on golang.org/x/net/html.

Layout: gw/ (gateway, no platform types, unit-tested against in-process IMAP/SMTP servers), sanitize/ (HTML mail sanitiser), cp/ (thin action handlers, DB for identities/preferences), schemas/, migrations/.

Sessions

webmail.session.open {address, password} -> {session, folders, expires_at, ...}. Every other action takes that session id. A session belongs to the panel principal that opened it (Actor.principal_id); nobody else can use or close it. It expires after 30 min idle (settings session_idle_minutes) or 12 h absolute, whichever first; closing it first delivers any message still inside its undo window. Max 10 sessions per principal. Failed sign-ins: 5 misses in 10 min lock that (principal, address) for 10 min; failures are audited. The IMAP host comes from mail.client.config called as the caller (so mail's access rules apply), never from the request. SSO (webmail.session.sso) checks the caller can see the mailbox (mail.mailbox.list), asks the mail module for its Dovecot master user (mail.sso.prepare, answered to the webmail module only; the master password is made on first use and kept in the mail module's vault) and signs in as address*rcmaster. A mailbox of another account (staff "Open") needs a fresh step-up, the session lasts at most 30 minutes, the audit log records who opened which mailbox (webmail.session.sso.staff) and the event webmail.sso.opened goes to the mailbox's account.

Module settings (JSON, schema schemas/settings.json)

host_override (dial this host, e.g. 127.0.0.1) + server_name (certificate name to verify), skip_tls_verify (self-signed mail cert, lab), imap_port/imap_security, smtp_port/smtp_security, sieve_port, max_attachment_mb (25), session_idle_minutes, session_max_hours, live_updates (IDLE, default on).

Actions (all permission-checked, schema-validated; see module.yaml)

Session: session.open/.sso/.close/.list/.watch. Folders: folder.list/.create/.rename/.delete/.empty/.subscribe. Reading: message.list, thread.list, message.search (folder * = every folder), message.get, message.source, attachment.get, message.reply_template. Organising: message.flags/.move/.delete/.archive/.spam. Composing: draft.save, message.send, send.cancel, upload.chunk/.discard. Rules: rules.get/.validate/.set, vacation.get/.set. Preferences: identity.list/.set/.delete, settings.get/.set, sender.trust. Events (owner-scoped, to the hosting account): webmail.mailbox.changed {session,address,folder,kind:exists|expunge|flags|sent,exists}, webmail.send.failed {detail}.

Reading mail safely (AC-webmail-08, -38)

sanitize.Clean parses with the HTML5 algorithm, then rebuilds a new tree from an allow-list; nothing is copied through unchecked. Dropped with content: script, style, iframe/frame, object/embed/applet, svg/math (any foreign namespace), template, noscript, head/title/meta/link/base, video/audio/source, select/textarea. Unwrapped (tags gone, text kept): form, input, button, label and every unknown element. No id, class, name, on*, srcset, formaction, background. Links: only http/https/mailto/tel survive, target=_blank rel="noopener noreferrer nofollow", and links[] reports each target with mismatch (visible text names another host) and idn (punycode look-alike). Inline style is reduced to presentational properties; any value with url(, expression, javascript, @import, \, var( is dropped; position, float, z-index are not allowed. Images: cid: -> data: URI of the inline part (raster only, <= 1 MB each, 3 MB total); data:image/svg never; http(s) images are **blocked** (data-rc-blocked, counted in remote_blocked) unless load_remote or the sender is on the trusted list (sender.trust); 1x1 / hidden images are tracking pixels: always dropped, counted in tracking_pixels. CSS url() never loads, even with opt-in. Test corpus: ~55 XSS vectors (OWASP classics, mXSS namespace tricks, Outlook conditional comments, null bytes, entity- encoded schemes), a tokenising oracle that re-parses the output and checks every tag/attribute/URL, and a fuzz target. **The UI must still render html in a sandboxed iframe (sandbox="", no allow-scripts/same-origin) with CSP default-src 'none'; img-src data: https:; style-src 'unsafe-inline'** — the sanitiser is layer one of two. Attachments: names are stripped of paths/control/RTL-override characters; executable extensions are flagged (dangerous); text/html, SVG, XML and JS attachments are served as application/octet-stream; chunked reads (attachment.get, 1 MiB default, 4 MiB max, base64) with the decoded part cached for the session; cap max_attachment_mb. Sender authentication shown from Authentication-Results (spf/dkim/dmarc).

thread.list uses server THREAD=REFERENCES (Dovecot) and falls back to grouping the newest 1000 messages by Message-ID/In-Reply-To/References and "Re:"-normalised subject. message.search parses operators (from: to: cc: subject: body: has:attachment is:unread|read|starred|answered label: before: after: larger: smaller:) into IMAP SEARCH; the remaining words are TEXT (Dovecot FTS when a plugin is configured). has:attachment is the heuristic Content-Type: multipart/mixed. Counts in lists are exact; pages are by sequence window (newest first).

Sending

SMTP submission with the mailbox's own login (587 STARTTLS; Postfix enforces login = sender, the refusal is returned as a readable conflict with smtp_code). Addresses/subject are validated (no CR/LF/NUL injection), Bcc only in the envelope, outgoing HTML is run through the sanitiser (scripts out, cid: kept), text part derived when missing, multipart/mixed > alternative > related structure, attachments from chunked uploads (spooled 0600 under the module data dir, wiped on session close; offset must match so a retry cannot duplicate bytes) or forwarded from the original. After sending: copy to Sent, \Answered / $Forwarded on the original, draft removed. delay_seconds 0-30 = undo window held in memory; send.cancel returns the message to Drafts; if a delayed send fails it is saved to Drafts and webmail.send.failed is emitted. Scheduled send is below ("Scheduled send and snooze"); read-receipt handling is not done.

Across folders (w11-24)

message.search and thread.list take folder *: every selectable folder except Trash, Junk, the Snoozed holding folder and a virtual "All mail" is searched with the server's own SEARCH (gw/allfolders.go); in:<folder|role> narrows it (and is the only way to search Trash or Junk). The newest 300 matches of each folder are looked at; a mail that lives in two folders (you sent it to yourself) is shown once. With threads: true the matches are grouped into conversations by Message-ID / In-Reply-To / References / "Re:"-subject across folders, and a conversation that matches is shown whole (its other messages are pulled in from the same folders), so a reply in Sent joins the mail it answers; every message carries its folder. No full-text index is installed by the mail module (no fts plugin in the Dovecot config), so the free text is a header + body scan; the result says so (full_text: false) and the UI shows "Header and body scan" with the reason. The result flips to full_text: true the day Dovecot advertises SEARCH=FUZZY.

Scheduled send and snooze (w11-24)

Both are database queues owned by this module (migration 0002: scheduled, snoozed), worked by one loop in the plugin process (10 s tick, cp/queue.go), so a closed browser or an expired webmail session loses nothing.

  • send.schedule {message, send_at, timezone} builds the finished RFC 5322 message now (same validation as send, so a bad address fails at once, attachments are inside the stored message) and stores it. A time zone name is required and is kept with the row; the instant is stored in UTC. 5 s to 366 days ahead, 100 per mailbox. The draft it came from is removed.
  • When due, the loop signs in with the Dovecot master user (mail.sso.prepare as the system actor, ADR 0018; the server comes from mail.client.config, now listed in system_calls) through an unregistered session (gw.OpenOffline) and uses the same hand-off as an immediate send (gw.DeliverRaw: submission, copy to Sent, \Answered/$Forwarded, draft removed). Postfix still enforces login = sender, so a scheduled message cannot do what an immediate one cannot. A 5xx answer marks the row failed (kept in Scheduled with the server's reason, webmail.send.failed event); a network error retries with 1, 2, 4, 8, 16 minutes, then fails. A claim is FOR UPDATE SKIP LOCKED; a sender that died mid-send is retried after 10 min.
  • scheduled.list, scheduled.edit (puts the message back into Drafts with its attachments and deletes the row), scheduled.cancel. Sent rows are history for 7 days with the body emptied.
  • Undo send is unchanged: a 0-30 s hold in memory of the session (it is delivered when the session closes). It is not durable across a restart of the plugin; the scheduled queue is.
  • message.snooze {folder, uids, wake_at, timezone} moves the messages to the folder Snoozed (created on first use; a normal folder, so every client and phone sees it) and records the Message-ID. At the time the loop moves the message back to the folder it came from, adds the keyword $Snoozed and clears \Seen, so it arrives as unread, newest, marked. A message deleted meanwhile just is not there. snooze.list, snooze.cancel (back now).

Mail connections per mailbox (w11-36)

The mail server allows ten connections per user and address. A mailbox session holds the command connection only while it works (given back after 45 s without work and reopened by the next operation) and one IDLE connection (push) only while a page is looking: the page sends session.watch every 30 s, and a session that heard nothing for 100 s parks IDLE and wakes by itself on the next request. A second sign-in to the same mailbox from the same panel session returns the existing session. All sessions of one mailbox share a budget of five connections (gw/conns.go); an open that cannot get one within 8 s answers "too many open mail connections", never "wrong password".

Calendar (w11-36)

Events live in the mailbox's own CalDAV calendars on the contacts-and-calendar service (the same sign-in and confinement as contacts; the service's port is read from the mail module's mail.client.config, never written here). gw/ical.go is the strict iCalendar reader/writer (512 KB per object, 8 MB and 3000 events per import, text cleaned like mail, ATTACH and other unmodelled lines never taken from mail or an import) and the repeat-rule expander (DAILY, WEEKLY, MONTHLY, YEARLY with INTERVAL, COUNT, UNTIL, BYDAY with ordinals, BYMONTHDAY, BYMONTH, BYSETPOS; finer frequencies refused; 200 000 iterations and 2000 occurrences per query; a window is at most 400 days). A stored rule is capped at COUNT=5000, because the calendar service itself expands a rule when it stores the event. Times are kept in the event's own zone (VTIMEZONE written), repeat rules are evaluated on the wall clock of that zone, and the screen draws everything in one display zone chosen in the toolbar. "This event / this and following / all" are RECURRENCE-ID overrides, UNTIL + a new series, and the master. gw/invite.go: an iCalendar part in a mail shows Accept / Maybe / Decline; the answer is the standard REPLY to the ORGANIZER of the invitation (shown to the person, and re-checked on the server), sent through the mailbox's own send path; updates, cancellations and guests' answers are applied only when the organiser matches. Reminders: a loop looks at the calendars of mailboxes whose Calendar was opened (cal_watch) once a minute through the master user and raises one bell notice per due reminder (core.notice.raise, grouped after four); only the last 15 minutes are considered, so an import of past events raises nothing; cal_fired stops a second notice. Nothing is mailed.

Contacts (w11-35) and the calendar to come

The mail stack has no DAV, so contacts are a separate small service, Radicale (Python, GPL-3.0, plain files per mailbox; radicale on Debian/Ubuntu, radicale3 from EPEL 9/10 on the RHEL family). It is off until an administrator runs mail.dav.set {enabled: true} (high risk: plan, step-up, a job). That installs the package (system.apt.ensure), then the agent op mail.dav.service writes /etc/radicale/config, the sign-in plugin /etc/radicale/rc/rc_auth.py, a systemd drop-in and the mail host's certificate for it, and starts it on port 5232 with TLS, all interfaces. mail.client.config reports dav (enabled, host, port) and the webmail Contacts screen shows an explanation (and, for an administrator, a "Switch on" button) while it is off.

  • Sign-in is the mail stack's own, never the panel session. The plugin does an IMAP login against Dovecot on 127.0.0.1:993 with what the client sent: the mailbox address and its password (an app password later: whatever Dovecot accepts), or address*masteruser and the master password, which is how the gateway signs in for a person who opened webmail without typing a password. The user the service sees is the mailbox address (everything after * is dropped), and rights = owner_only confines it to /<address>/. Proven by lab/tests/dav-isolation.sh (two mailboxes, 16 attempts) and by two mailboxes in the webmail.
  • The gateway is a DAV client (gw/dav.go): it addresses only /<own address>/contacts/ (MKCOL on first use), over the same verified TLS as IMAP (the mail node's pinned self-signed certificate or a CA one), with the session's own credentials. Actions: webmail.contacts.list/save/delete/import/export/group. Optimistic concurrency by ETag (a phone's change in between gives a clear conflict, not a lost update).
  • vCards are untrusted data (gw/vcard.go): one card at most 256 KB, an import 8 MB / 3000 cards, every value stripped of control and bidi-override characters and truncated, e-mail addresses validated, only http(s) URLs kept, group names without separators, what the webmail writes back is generated (vCard 3.0), never echoed; properties the webmail does not model (PHOTO, X-*) are kept as opaque lines so a phone's data survives an edit and are never interpreted or fetched. A card UID that is path-shaped is replaced.
  • Recipient suggestions come from the address book first, then from the people in the loaded mail.
  • Not yet: app passwords (Dovecot's passwd-file passdb takes one password per user; needs a second passdb or SQL), the service behind the web server on 443 with /.well-known/carddav (Radicale itself redirects /.well-known/carddav to / on its own port), certificate renewal reaching the service's copy, the firewall opening for 5232, a calendar screen (w11-36 uses the same service: CalDAV collections are allowed already).

Rules (ManageSieve) and vacation

Rules are structured JSON (conditions on from/to/cc/subject/header/size/body; actions fileinto/copy/mark read/flag/label/ redirect/discard/stop), rendered to Sieve by BuildSieve with every value quoted/validated and the rules embedded in a comment for exact round trip, stored as script rc-webmail and activated through port 4190; the server's own syntax error is returned verbatim. The rules script is the user's active script; the out-of-office reply lives in a separate file the mail module owns (~/.rc-vacation.sieve, a per-user before script), so the two never share a file (mail/012). rules.set never switches off another active script (one written by an older install or another client): it is pulled into rc-webmail with include :personal before the rules (BuildSieveWith), as is any script the previous rc-webmail already included; replace_foreign drops them on purpose. Saving a rule therefore cannot lose an out-of-office reply. vacation.set calls mail.autoresponder.set/.delete as the caller and mirrors the values in settings.

Data (Postgres schema of the module)

identities (owner account + mailbox, email, name, reply-to, signatures; limit key webmail.identities 20), settings (<= 16 KiB JSON per mailbox, vacation mirror under key vacation), trusted_senders. Keyed by the hosting account so a mailbox shared by sub-users shows one set.

Verified

See docs/tasks/w4/w4-06-webmail-backend.handover.md (unit + lab results).

Not done / next

Calendar (CalDAV on the same service), app passwords, one-click unsubscribe POST (needs an SSRF-safe fetcher), push, S/MIME/PGP, delegation, attachment zip, FTS tuning/benchmark on 100k mailboxes, message preview snippets (IMAP PREVIEW), remembered sessions via the vault, a panel-session-id binding (the actor has no session id; binding is to the principal), per-folder IDLE for several folders at once, Sieve include integration with the mail module (above).

Remote images (w11-13)

The panel's content-security policy (img-src 'self' data:) is inherited by the sandboxed frame that shows a message, so a remote https: image never loads in a browser, whatever the frame's own policy says (proved in Chromium by web/apps/panel/scripts/ui-browser.test.mjs). The panel's policy is not loosened. When the person presses "Show images" (or trusts the sender) the gateway fetches each image itself (gw/inlineimg.go: the egress guard refuses private, loopback and metadata addresses, no redirects, PNG/JPEG/GIF/WebP/AVIF sniffed from the bytes, SVG never, 2 MiB each, 40 and 8 MiB per message) and puts it into the message as a data: image.