Mail

Mail server with domains, mailboxes, aliases, forwarders, catch-all, autoresponders, DKIM/SPF/DMARC/MTA-STS and queue control.

In preview mail version 1.0.0 Part of Mail and webmail

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.

ActionWhat it doesRiskPreview
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.manage Manage the mail server
  • mail.server.view View the mail server
  • mail.domain.manage Add and remove mail domains
  • mail.domain.view View mail domains
  • mail.dkim.manage Manage DKIM keys
  • mail.dmarc.manage Manage DMARC policy
  • mail.mtasts.manage Manage MTA-STS and TLS-RPT
  • mail.mailbox.manage Create, change and delete mailboxes
  • mail.mailbox.view View mailboxes
  • mail.mailbox.password Reset mailbox passwords
  • mail.alias.manage Manage aliases, forwarders and the catch-all
  • mail.autoresponder.manage Manage autoresponders
  • mail.queue.view View the mail queue
  • mail.queue.manage Act on the mail queue
  • mail.limit.manage Set outbound limits
  • mail.abuse.manage Hold and release senders
  • mail.client.view View mail client settings

Plan limits

  • mail.domains Mail domains
  • mail.mailboxes Mailboxes
  • mail.forwarders Forwarders

Engineering notes

Generated from modules/mail/docs.md at build e0cdf300. 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 vmail system 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). Rspamd dkim_signing + arc use dkim_selectors.map. Ed25519 not yet.
  • Rotation (two steps): mail.dkim.rotate makes a pending key and publishes it; activate=true (or immediate) switches signing and keeps the old selector published 7 days (retired, removed by mail.reconcile).
  • TLS: self-signed cert at /etc/rc-mail/tls/{fullchain,privkey}.pem until 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-office vacation, date window via the date extension with a UTC-offset timezone; a comment-only script when off), rendered for every mailbox on every sync and loaded by Dovecot as a per-user sieve_before script (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.sieve belongs to ManageSieve clients (webmail saves its rules as script rc-webmail and activates it); the mail module never writes or includes it, so neither side can overwrite the other and there is no config_drifted on 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.sieve and become the active script again.
  • Catch-all (mail/011): @domain -> target sits in virtual_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_created only when the single mail server has auto_provision on (never publish MX for a domain nobody asked mail for). domains.deleted removes 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:

  1. The web stack (web.panel_host.ensure with upstream: dav, nginx block webPanelRenderDAV): 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/carddav and /.well-known/caldav redirect 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.
  2. The guard inside the service's process (rc_guard.py, started by rc_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.
  3. 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 CalDAV valid-calendar-data precondition. The port. The service listens on 127.0.0.1:5232 in plain HTTP; the firewall never opens it (the mail preset lists no 5232) and another machine on the lab network cannot connect. mail.client.config reports dav.port = 443 and base_url = https://<host>/; the webmail gateway reads that (no change in the webmail). mail.dav.set needs the web server on the mail server's own machine. Proved by lab/tests/dav-guard.sh and lab/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) and X-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-For is 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 field rc_slot=N of the slot files). A refused sign-in is repeated once, in the background, on the IMAP port from 127.0.0.2 with the address in the IMAP ID command, so Dovecot's own imap-login ... (auth failed ...) rip=<client> line (what the ban rules read) names the client. Dovecot trusts the ID only from login_trusted_networks = 127.0.0.2 (/etc/dovecot/rc-dav.conf, included by the main file); an nft rule in its own table rc_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.set says client_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 op mail.dav.lastuse hands 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.