Actions
77 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 |
|---|---|---|---|
mail.server.enable | Enable mail on a server | critical | |
mail.hostname.ensure | Certificate for a mail hostname | medium | |
mail.server.config.apply | Apply mail server settings | high | |
mail.options.get | Mail server options (read) | low | No dry run |
mail.relay.set | Send mail through a relay | high | |
mail.relay.test | Send a test message | medium | |
mail.filters.set | Spam filter and antivirus | high | |
mail.server.services | Size the mail filters for this server | high | |
mail.dav.set | Contacts and calendar sync | high | |
mail.dkim.selector.set | Default DKIM selector | low | |
mail.config.status | Hand-edited mail files (read) | low | No dry run |
mail.config.adopt | Keep a hand edit on top of the panel's configuration | medium | |
mail.config.reapply | Render the panel's version again | medium | |
mail.config.reset | Reset a file to defaults | medium | |
mail.config.unmanage | Hand a file off to the administrator | high | |
mail.server.selftest | Mail server self-test (read) | low | No dry run |
mail.server.get | Mail server settings (read) | low | No dry run |
mail.reconcile | Reconcile mail servers | medium | No dry run |
mail.domain.add | Add a mail domain | medium | |
mail.domain.list | List mail domains (read) | low | No dry run |
mail.domain.get | Get a mail domain (read) | low | No dry run |
mail.domain.delete | Remove a mail domain | high | |
mail.domain.dns.check | Check mail DNS records (read) | low | No dry run |
mail.domain.dns.fix | Publish mail DNS records | medium | No dry run |
mail.dkim.rotate | Rotate the DKIM key | medium | No dry run |
mail.dmarc.set | Set DMARC policy | medium | No dry run |
mail.mtasts.set | Set MTA-STS and TLS-RPT | medium | No dry run |
mail.catchall.set | Set the catch-all | medium | No dry run |
mail.mailbox.create | Create a mailbox | medium | |
mail.mailbox.list | List mailboxes (read) | low | No dry run |
mail.mailbox.update | Change a mailbox | medium | No dry run |
mail.mailbox.delete | Delete a mailbox | high | |
mail.mailbox.password.set | Reset a mailbox password | medium | No dry run |
mail.apppw.create | Make an app password | medium | |
mail.apppw.list | List app passwords (read) | low | No dry run |
mail.apppw.matches | Is this password an app password (read) | low | No dry run |
mail.apppw.revoke | Revoke an app password | medium | |
mail.alias.set | Create or change an alias | low | No dry run |
mail.alias.list | List aliases and forwarders (read) | low | No dry run |
mail.alias.delete | Delete an alias | low | No dry run |
mail.forwarder.set | Create or change a forwarder | low | No dry run |
mail.forwarder.delete | Delete a forwarder | low | No dry run |
mail.autoresponder.set | Set an autoresponder | low | No dry run |
mail.autoresponder.get | Read a mailbox's autoresponder (read) | low | No dry run |
mail.autoresponder.delete | Delete an autoresponder | low | No dry run |
mail.queue.list | List the mail queue (read) | low | No dry run |
mail.queue.retry | Queue: retry messages | medium | No dry run |
mail.queue.hold | Queue: hold messages | medium | No dry run |
mail.queue.release | Queue: release messages | medium | No dry run |
mail.queue.delete | Queue: delete messages | high | |
mail.queue.flush | Queue: flush | medium | No dry run |
mail.limit.set | Set outbound limits | medium | No dry run |
mail.account.hold | Hold outbound mail of a mailbox | medium | No dry run |
mail.account.release | Release a held mailbox | medium | No dry run |
mail.autohold.list | Mailboxes held for sending too much (read) | low | No dry run |
mail.autohold.release | Release a held mailbox | medium | |
mail.autohold.discard | Discard held mail | high | |
mail.mailbox.burst.set | Allow a mailbox to send bursts | medium | |
mail.dmarc.summary | DMARC reports of a domain (read) | low | No dry run |
mail.reputation.get | Reverse name and blocklist status (read) | low | No dry run |
mail.reputation.check | Check reverse name and blocklists now | low | No dry run |
mail.reputation.set | Reputation check settings | medium | |
mail.deliverability.fix | Fix a deliverability finding | medium | |
mail.sso.prepare | Single sign-on credentials for webmail | medium | No dry run |
mail.client.config | Mail client settings (read) | low | No dry run |
mail.names.repair | Repair mail names that point at the wrong server | medium | |
mail.config.reset_all | Reset the whole service to defaults | high | |
mail.config.snapshot.list | Snapshots before resets (read) | low | No dry run |
mail.config.snapshot.restore | Undo a reset (restore a snapshot) | high | |
mail.config.history.list | Earlier versions of a file (read) | low | No dry run |
mail.config.history.diff | Compare an earlier version (read) | low | No dry run |
mail.config.history.restore | Restore an earlier version of a file | high | |
mail.unmanaged.scan | Scan now for objects made outside the panel | low | No dry run |
mail.unmanaged.list | Objects made outside the panel (read) | low | No dry run |
mail.unmanaged.ignore | Ignore or un-ignore an unmanaged object | low | |
mail.unmanaged.adopt | Adopt an object made outside the panel | medium | |
mail.unmanaged.remove | Remove an object made outside the panel | high |
Permissions and limits
Permissions
mail.server.manageManage the mail servermail.server.viewView the mail servermail.domain.manageAdd and remove mail domainsmail.domain.viewView mail domainsmail.dkim.manageManage DKIM keysmail.dmarc.manageManage DMARC policymail.mtasts.manageManage MTA-STS and TLS-RPTmail.mailbox.manageCreate, change and delete mailboxesmail.mailbox.viewView mailboxesmail.mailbox.passwordReset mailbox passwordsmail.alias.manageManage aliases, forwarders and the catch-allmail.autoresponder.manageManage autorespondersmail.queue.viewView the mail queuemail.queue.manageAct on the mail queuemail.limit.manageSet outbound limitsmail.abuse.manageHold and release sendersmail.client.viewView mail client settings
Plan limits
mail.domainsMail domainsmail.mailboxesMailboxesmail.forwardersForwarders
Engineering notes
Generated from modules/mail/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.
Desired state lives in the panel DB (mail_* tables, mailboxes). Every mutation re-renders the whole node
configuration in the control plane (cp/render.go, templates in templates/) and sends it to the agent with
mail.files.apply; the agent writes only a fixed set of file kinds (sdk/agentop/mail.go), atomically, validates with
postfix check / doveconf -n / rspamadm configtest / sievec, restores the previous files on failure, and reloads
only the services whose files changed. Idempotent; mail.reconcile re-applies everything.
Decisions
- Storage: all mailboxes are virtual users owned by one
vmailsystem user:/var/vmail/<domain>/<local>/Maildir. Per-account Unix ownership was rejected: Dovecot LMTP/IMAP run as one uid and hosting accounts are shell users who should not read mail. Quotas are Dovecot maildir quotas (userdb_quota_rule), not filesystem quotas. - Passwords: bcrypt (
{BLF-CRYPT}$2a$10$) hashes in the panel DB, exported to a Dovecot passwd-file (/etc/dovecot/rc-users, 0640 root:dovecot). One password store: Postfix authenticates through Dovecot SASL. Argon2 was skipped (memory cost per login under Dovecot's auth worker, libsodium dependency). - Maps: Postfix
texthash:files (no postmap step) for domains, mailboxes, aliases, sender-login, held senders. - DKIM: per-domain RSA keys generated on the node (
/var/lib/rspamd/dkim/<domain>.<selector>.key, 0640 root:_rspamd); the private key never enters the DB, results, logs or events (only the public key is stored). Rspamddkim_signing+arcusedkim_selectors.map. Ed25519 not yet. - Rotation (two steps):
mail.dkim.rotatemakes a pending key and publishes it;activate=true(orimmediate) switches signing and keeps the old selector published 7 days (retired, removed bymail.reconcile). - TLS: self-signed cert at
/etc/rc-mail/tls/{fullchain,privkey}.pemuntil ssl installs one at the same path. - Ports: 25 (STARTTLS), 587 (STARTTLS, auth), 465 (implicit TLS, auth), 143/993 IMAP, 4190 ManageSieve.
Submission requires login = sender (
reject_sender_login_mismatch) and refuses held / outbound-disabled senders. - Sieve layout (mail/012): two owners, two files. The mail module owns
~/.rc-vacation.sieve(the out-of-officevacation, date window via thedateextension with a UTC-offsettimezone; a comment-only script when off), rendered for every mailbox on every sync and loaded by Dovecot as a per-usersieve_beforescript (after the global spam filter, so the reply runs ahead of the user's rules and a rule that files or discards a message never silences it). The user's active script~/.dovecot.sievebelongs to ManageSieve clients (webmail saves its rules as scriptrc-webmailand activates it); the mail module never writes or includes it, so neither side can overwrite the other and there is noconfig_driftedon a file nobody edited. Mailboxes written by the old layout are migrated by the agent on the next sync: the old managed wrapper is removed, and webmail rules that had overwritten it move to~/sieve/rc-webmail.sieveand become the active script again. - Catch-all (mail/011):
@domain -> targetsits invirtual_alias_maps, which Postfix reads before the mailbox map and again for every alias destination. Each mailbox of a catch-all domain therefore gets an identity line (info@d info@d) ahead of it, so existing mailboxes and alias destinations are never swallowed; a mailbox that is itself an alias key keeps that mapping. - Auto-provision:
domains.created->mail.on_domain_createdonly when the single mail server hasauto_provisionon (never publish MX for a domain nobody asked mail for).domains.deletedremoves the mail domain but keeps the maildirs on disk (orphans under/var/vmail/<domain>). - Limits:
mail.domains,mail.mailboxes,mail.forwarders(Reserve/Release, compensated on failure).
DNS records (per mail domain)
MX @ -> 10 <hostname>.; SPF @ TXT v=spf1 mx ~all; DKIM <selector>._domainkey TXT v=DKIM1; k=rsa; p=… (pending/
active/retired selectors); DMARC _dmarc TXT (starts p=none); MTA-STS _mta-sts TXT + mta-sts CNAME -> host and TLS-RPT
_smtp._tls TXT when enabled; autoconfig and autodiscover CNAME -> host. Publishing calls
dns.record.upsert_managed with {domain, owner_module:"mail", key, record:{name,type,ttl,value}} for each record
(assumption from docs/specs/dns.md §6; zone resolved by domain since w2-01 may use zone_id). If that call fails for any
reason (module missing, zone not hosted here) the action returns dns: {published:false, records:[…]} to copy. The
DKIM value can exceed 255 chars: the dns module must split it into TXT strings.
MTA-STS policy text is written to /var/lib/respirecloud/mail/mta-sts/<domain>/.well-known/mta-sts.txt for the web
module to serve at https://mta-sts.<domain>/.well-known/mta-sts.txt.
Verified in the lab (2026-10-09, FAKENET=full, Ubuntu 24.04, Postfix 3.8.6, Dovecot 2.3, Rspamd 3.8.1)
enable (apt + config + self-test all green) · domain add (DKIM + files) · inbound from "outside" to a mailbox read over
IMAPS · bad IMAP password refused · submission 587 with auth -> relay -> Mailpit, DKIM-Signature + ARC seal present and
dkim.verify = True (also after rotation, new selector) · sender mismatch 553 · open relay refused · no auth without TLS
· alias, forwarder with keep-copy, catch-all, loop rejected, autoresponder reply received, held and send-disabled senders
rejected, quota-exceeded -> 552 bounce · usage via doveadm · queue list/hold/release/retry/delete/flush · dry-run config
diff · domain deletion event keeps the maildir.
Not done (follow-ups)
Relays/smarthost per domain, IP pools, suppression lists, send API/webhooks, distribution lists, raw Sieve/filters UI, app passwords, DMARC report parsing, deliverability checker, mail logs, IMAP import, POP3, DANE, per-mailbox/domain outbound limit enforcement (stored; only node limit via Rspamd ratelimit + Redis, and a per-IP submission rate are enforced), auto-hold on abuse, ClamAV, mailbox rename, mobileconfig/QR, UI, quota warnings at 80/90 %.
Moves (w11-09)
A mail domain moves between mail servers with its account: mail.move.plan|copy|cutover|abort|purge (internal, called by the cluster module's move; docs in modules/cluster/docs.md).
mail_relays marks a domain on a node as hold (queued, the freeze) or forward (relayed to the node that holds it now: transport_maps + relay_domains in the node's Postfix); mail_kept the copy an old node
keeps until purge_source; mail_moves the phase of each domain of a running move. mail.names.repair repairs mail names that name a server which does not hold the mailbox.
Contacts and calendar service (Radicale) behind a guard (w11-48)
The problem. The service expands a repeat rule when it stores an event (to index its time range). Measured in the lab against the
service as a signed-in mailbox user, before the guard: FREQ=DAILY;COUNT=999999999 from year 2000 = 13.0 s of CPU for one PUT;
FREQ=SECONDLY;COUNT=999999999 = no answer after 40 s (the service's threads stayed busy and a restart waited 90 s for them);
FREQ=DAILY;UNTIL=99991231 = 36 s; FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30;COUNT=1 (a day that never happens) = 30 s; the same endless
= no answer. The service sat on its own public port with nothing in front of it. After: each of these is refused with 403 in 7-16 ms.
Where it lives. Three layers, each doing what only it can:
- The web stack (
web.panel_host.ensurewithupstream: dav, nginx blockwebPanelRenderDAV): the mail host name on the standard HTTPS port with the certificate of the ssl module (a renewal rewrites the files and reloads nginx; the service holds no certificate copy any more),/.well-known/carddavand/.well-known/caldavredirect to/; request time limit 20 s, body limit 10 MB, a rate per mailbox (the Basic user name, master-user form included: 120 requests/s burst 400, 60 writes/s), at most 10 requests at once per mailbox and 40 per address, requests with no sign-in 10/s per address; answers 429. - The guard inside the service's process (
rc_guard.py, started byrc_serve.py, which wraps the service's WSGI application): a PUT/POST body is read once (up to 10 MB), checked, and handed on, so the filter covers one event, a PUT of a whole calendar and an import alike and nothing reaches the service's expander unchecked. Chosen over a filter in nginx (no body inspection without a module that is not on every distribution) and over a separate proxy the agent would run (a second process, a second port, a second thing to restart and to forget); the plugin API of the service has no body hook, the WSGI seam is stable across versions. Because it is in the process, even a request that bypasses nginx on the loopback port is checked. - The resource slice
rc-dav.slice(CPUQuota 100 %, MemoryHigh 256 M, MemoryMax 384 M, TasksMax 128, Nice 10, OOMScoreAdjust 500): what still gets through cannot take more than one core, so mail delivery (Postfix, Dovecot, Rspamd in other slices) keeps flowing. Bounds (the webmail's own,modules/webmail/gw/ical.go): FREQ DAILY/WEEKLY/MONTHLY/YEARLY only; only INTERVAL (1-1000), COUNT (at most 5000), UNTIL, BYDAY, BYMONTHDAY, BYMONTH, BYSETPOS, WKST; one rule per event of at most 600 bytes; at most 3000 events and 1000 EXDATE/RDATE values per request; a finite rule may walk at most 5000 repeat periods and 200 years from its start; a rule that can never happen (30 February) is refused; a daily or weekly rule limited to months or days of the month needs an end date (otherwise the service would search up to the year 9999). Endless rules (FREQ=DAILY, what every phone creates for "every day") are allowed. Rules that expand for long (endless, or more than 300 periods) are run one at a time (a second waits up to 2 s, then 503 with Retry-After). Consequence: a phone event with BYWEEKNO / BYYEARDAY / BYHOUR or COUNT over 5000 is refused with a plain reason in the answer body (the webmail would not show it expanded either). The answer is 403 with the CalDAVvalid-calendar-dataprecondition. The port. The service listens on127.0.0.1:5232in plain HTTP; the firewall never opens it (themailpreset lists no 5232) and another machine on the lab network cannot connect.mail.client.configreportsdav.port = 443andbase_url = https://<host>/; the webmail gateway reads that (no change in the webmail).mail.dav.setneeds the web server on the mail server's own machine. Proved bylab/tests/dav-guard.shandlab/tests/dav-isolation.sh(32/32 through the standard port).
The real client address of a phone's sign-in (w11-60)
The contacts and calendar service signs in to Dovecot from the machine itself, so every failure used to be logged as rip=127.0.0.1
(nothing banned the client, nothing could say where an app password was used). Now:
- nginx (the standard HTTPS port) sets
X-RC-Client(the address it saw) andX-RC-Key; the key is a random string in/etc/radicale/rc/proxy.key(0400, service user) and/etc/rc-dav/nginx-key.conf(0600 root, included by the mail host's nginx block). The guard inside the service (rc_guard.client_ip()) accepts the address only with the right key;X-Forwarded-Foris never believed, so a shell user sending a request straight to the loopback port is seen as the machine. - The sign-in asks Dovecot's auth service over
auth-rcdav, a socket only the service user can open (mode 0600), with the client's address (the answer also says which app password matched: the passwd-file extra fieldrc_slot=Nof the slot files). A refused sign-in is repeated once, in the background, on the IMAP port from127.0.0.2with the address in the IMAP ID command, so Dovecot's ownimap-login ... (auth failed ...) rip=<client>line (what the ban rules read) names the client. Dovecot trusts the ID only fromlogin_trusted_networks = 127.0.0.2(/etc/dovecot/rc-dav.conf, included by the main file); an nft rule in its own tablerc_dav_trust(rc-dav-trust.service,Before=dovecot) refuses every connection from that source made by anyone but the service user, so a customer's shell cannot forge an address. The trust file is written only after the rule is loaded and checked, and removed when the service is turned off;mail.dav.setsaysclient_address: not forwarded: <why>when it cannot be put in place. - Last use: the service notes
time, mailbox, app password, address(at most one line a minute per triple) in/var/lib/radicale/rc-lastuse; the agent opmail.dav.lastusehands the lines over when app passwords are listed. Only contacts and calendar sign-ins are recorded; the address is the one the web server saw (a phone's mobile-network address is shared and can change), the screen says so. - Dovecot's own penalty delay now applies to a repeat offender's address, so a guessing client waits longer for each wrong password.