Webmail

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

In preview webmail version 0.1.0 Part of Mail and webmail

Actions

39 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 high No dry run
webmail.folder.empty Empty Trash or Junk high No dry run
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.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

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.settings.manage Manage webmail preferences

Plan limits

  • webmail.identities Sending identities per account

Engineering notes

Generated from modules/webmail/docs.md at build d90e9e2. 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, 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, snooze and read-receipt handling are not done (see below).

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 still refuses (conflict, replace_foreign) when a script from another client is active. 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

Scheduled send and snooze (needs a durable scheduler: store in Drafts-like folder + mail/module schedule), contacts and calendar (CardDAV/CalDAV), 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).