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.
| Action | What it does | Risk | Preview |
|---|---|---|---|
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.useSign in to a mailbox in webmailwebmail.session.ssoOpen webmail for a mailbox without its passwordwebmail.mail.readRead and search mailwebmail.mail.writeFlag, move, archive and delete mailwebmail.mail.sendWrite and send mailwebmail.folder.manageCreate, rename and delete folderswebmail.rules.manageManage mail rules and the out-of-office replywebmail.identity.manageManage sending identities and signatureswebmail.settings.manageManage webmail preferences
Plan limits
webmail.identitiesSending 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).
Conversations and search
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).