Mailbox filters & autoresponders
Purpose. Per-mailbox mail sorting (cPanel “Email Filters”) and out-of-office replies (“Autoresponders”), executed by Dovecot’s Sieve interpreter at delivery time. Admins edit structured rules in the admin UI; the API compiles them to one Sieve script and pushes it into Dovecot through ManageSieve. Power users can replace the generated script with raw Sieve.
Behaviour.
GET /api/v1/mailboxes/{id}/filters→{ mailboxId, mode, rules, raw, autoresponder, script, pushedAt, updatedAt }. A mailbox with no filters returnsmode: "rules", emptyrules,script: "",pushedAt: null.PUT /api/v1/mailboxes/{id}/filters{ mode: "rules", rules }or{ mode: "raw", raw }. Rules mode compiles rules + the stored autoresponder to Sieve; raw mode pushesrawverbatim (the autoresponder settings are kept but not applied — include your ownvacationcommand). The script is uploaded as the ManageSieve script namedmailserverand set active; the previous script stays active if Dovecot rejects the new one (422 sieve_rejected, message = Pigeonhole’s compile errors) or is unreachable (502 sieve_unavailable). Nothing is stored in the database unless the push succeeded.POST /api/v1/mailboxes/{id}/filters/check{ script }→{ ok, message }— server-side syntax check (ManageSieveCHECKSCRIPT) without storing.POST /api/v1/mailboxes/{id}/filters/compile(and/mail/filters/compile){ rules }→{ script }— the Sieve those rules compile to, without pushing or storing it: the editor’s live preview, so what it shows is what saving would push. An incomplete rule answers400 validationnaming what is missing.POST /api/v1/mail/filters/try{ rule, folder }→{ folder, total, examined, matched, messages }— Try it on your mail: which of the folder’s messages one rule matches, read by the API over IMAP and changing nothing. It reads the newest 1,000 messages (250 when the rule reads the body, which costs a download each;examinedsays how many), andmessagesis the newest 20 matches. Webmail only: there is no administrator’s equivalent, because it would show an administrator a mailbox’s senders and subjects outside the audited hand-off by which an administrator opens a mailbox; an administrator who needs it opens the mailbox that way. The matcher (apps/api/src/services/filter-try.ts) reads each condition as the compiled script does (case-insensitive comparisons, the day and time from the message’s arrival, the body as its decoded text), and the Compose smoke holds the two together: it tries four rules on a fixture set, installs them, delivers the set again and compares the counts.POST /api/v1/mail/filters/apply{ rule, folder }→{ folder, total, examined, matched, skipped }— a filter run on mail already stored, from Run on existing mail in a filter’s menu or Also apply it to these when I save in the editor. It runs on exactly the messages Try it counts (the same matcher, the same newest 1,000 or 250), through IMAP: the rule’s flags, labels, pins and mark as read first, then its copies, then its move (to a folder, Trash or Junk; a folder that does not exist is made, as delivery’sfileintomakes it), or a delete for Delete permanently with nowhere to file. Forward, reply and refuse are never run — they would send mail about messages that arrived long ago — and come back inskipped; a skipped forward leaves the message where it is, although at delivery it would have taken it away. Webmail only, like Try it. Pigeonhole’ssieve-filteris not what runs it: it runs inside the dovecot container, which the API reaches only through ManageSieve and IMAP, and a new privileged agent there for this was not worth it when the matcher is already held to the compiler by the smoke. The smoke runs a rule with a move, a label, a forward, a reply and a refusal on its fixtures and checks it moved and labelled exactly what Try it counted and that nothing reached alice.GET/PUT /api/v1/mailboxes/{id}/autoresponder{ enabled, subject (≤ 200 characters), body (≤ 10,000), intervalDays (1–30, default 1), startsAt?, endsAt? }— compiled into the same script as avacationcommand guarded bycurrentdatewhen a window is set. Emptysubject= Pigeonhole’s default (Auto: <original subject>). Each sender is answered once perintervalDays; replies go through Postfix (submission_host = postfix:25) and are suppressed by Sieve for list mail, auto-submitted mail and messages not addressed to the mailbox.- The window is chosen as whole days: one Away field opens a calendar (a popover on a wide screen, a bottom sheet on a phone) and stores
startsAtas the first moment of the first day andendsAtas the last moment of the last, in the browser’s time zone, which the field names. Either end may be left open. It replaced twodatetime-localinputs labelled UTC that did not round-trip — they parsed what was typed as local time and showed it back as UTC, so the window moved after saving for anyone not on UTC. - Rules (
packages/core/src/sieve.ts):{ name, enabled, match: all|any, conditions[], actions[], stop }, evaluated in order. One rule holds 1–20 conditions and 1–10 actions (MAX_FILTER_CONDITIONS/MAX_FILTER_ACTIONS).- Conditions:
header(any header, or exists),address(From/To/Cc/Sender/Reply-To; whole address, local part or domain),recipient(any address in To or Cc),not_to_me(the mailbox’s own address is in neither To nor Cc),list(carriesList-Id),subject_or_body,body(contains / not contains),has_attachment,attachment_name,attachment_type(a MIME type such asapplication/pdforimage/*),high_priority(X-Priority1 or 2,Importance: high,Priority: urgent),spam_score(over / under a whole number),automated(Auto-Submitted, bulkPrecedenceorList-Unsubscribe),weekdayandtime_of_day(in a zone),size(over / under, bytes). - Tests on text: contains, is, matches (Sieve’s
*and?wildcards), starts with, ends with (the value’s own*and?are matched literally) and a regular expression (POSIX extended, Pigeonhole’sregex), each negatable. - Actions:
fileinto(move; folders are created on first use),copy,trashandjunk(into the folders namedTrashandJunk: Pigeonhole here has nospecial-use),redirect,redirect_copy(forward a copy),flag(\Seen,\Flagged,\Answered,\Deleted),label(a label’s keyword),mark_read,important(\Flagged),pin($Pinned),reply(an automatic reply,vacationwith a handle named after the reply’s text, so each sender hears each reply at most once a day, and a rewritten reply counts as a new one; a handle naming the rule’s position would have kept a rewritten reply silent and crossed over when rules were reordered),reject(refuse, with a reason sent back to the sender),discard,keep. Flag-setting actions are emitted before delivery actions so the flag applies to the stored copy. - The spam score reads Rspamd’s
X-Spamd-Result, which only mail from the internet carries: mail from a trusted network or an authenticated sender never matches over and never matches under either. A negative score never matches over and always matches under. - A zone is an offset (
+0100), not a zone name: Sieve’scurrentdate :zonetakes nothing else (RFC 5260), so a rule set in winter is an hour out after the clocks change.time_of_daywithfromaftertoruns across midnight. - Not addressed to me compiles against the mailbox’s own address, which the API passes when it compiles; mail sent to one of its aliases counts as not addressed to it.
stop: true(default) ends processing after a match, like cPanel.
- Conditions:
- The list: each filter is one row read as a sentence (its conditions and actions as tokens, then stop when it stops), with a switch, a drag handle and a menu (Edit, Duplicate, Move up, Move down, Delete after a confirm). Every change saves the whole list at once and says so in a toast; a refusal shows Pigeonhole’s message and puts the list back. More holds Edit as Sieve, Export as a .sieve file (the active script) and Import from a .sieve file, which opens the file in the raw editor and saves nothing until Save raw script. While a raw script is active the list is shown but cannot be changed, and Use these filters instead switches back; saving rules keeps the raw script as written, so switching back loses nothing.
- The editor: name and Turned on, all/any, conditions picked from groups (Sender and recipients, Message, Signals, When it arrives, Advanced) and actions from Organise, Remove and Respond, Stop here, and the Sieve it compiles to. The generated script starts with a comment and a sorted
requirelist — it is also the starting point when switching to the raw editor. - Filter messages like this, in an open message’s More menu, opens the editor on a new filter filled in from that message:
List-Id contains <its list>when it came from a mailing list (a list’s posts come from many senders), otherwiseFrom is <its sender>, andSubject contains <its subject>without theRe:andFwd:replies add. Every condition must hold, so it starts narrow and is widened by removing one. The message’s list comes fromlistIdon the message (GET /mail/messages/{uid}), read off the rawList-Idline: mailparser files a bare<id>under the wrong field. - Every change is audited (
mailbox.filters_update,mailbox.autoresponder_update, with before/after), whether an administrator made it here or the owner in webmail Settings, which the audit log names as… by <address>. - Transport. The API logs into
dovecot:4190as the Dovecot master usermailserver-apiwith SASL PLAINauthzid=<mailbox address>. Dovecot accepts the master user only from loopback and the Compose subnet the dovecot container is on, which its entrypoint writes into/etc/dovecot/master-passdb.confat every start, so this login works from the api container and is refused from any other address Dovecot sees. The address is the one Dovecot sees. Where Docker’s proxy carries a connection instead of iptables DNAT — an IPv6 client on a host whose compose network has no IPv6, the userland proxy, rootless Docker — a client appears as the bridge gateway, which is inside that subnet, and is allowed — on IPv4; with IPv6 on, the IPv6 gateway is left out of the list. The gateway path also means Postfix accepts unauthenticated relay from that client throughmynetworks. The password isDOVECOT_MASTER_PASSWORDin.env(generated bymailctl install; the Dovecot entrypoint hashes it into/etc/dovecot/master-users). When Dovecot requires TLS (tls.mode≠none) the client issues STARTTLS first; the certificate is not verified because the service name is not the certificate’s hostname and the traffic never leaves the compose network. - Scripts live in the mailbox’s home (
/var/vmail/<domain>/<local>/sieve/mailserver.sieve, symlinked from.dovecot.sieve), so backup/restore of thevmailvolume carries them; the database row (mailbox_sieve) is the editable source and can re-push at any time with aPUT.
Configuration. SIEVE_TRANSPORT=managesieve|noop (API; noop for tests/OpenAPI export), SIEVE_PORT (4190), DOVECOT_MASTER_USER (mailserver-api), DOVECOT_MASTER_PASSWORD. Dovecot side: auth_master_user_separator = * (rendered), the master passwd-file passdb restricted by allow_nets = loopback plus the container’s own subnet (written by the entrypoint and !included, not rendered), lda_mailbox_autocreate/autosubscribe = yes, submission_host = postfix:25. Redirects and vacation replies to outside addresses relay through that port without authenticating, allowed because the dovecot container is on the Compose subnet Postfix trusts (Relaying & trusted networks). The smoke proves a redirect still leaves, and that one rule for each condition, test and action compiles, passes CHECKSCRIPT and acts on a delivered message.
Limits & edge cases. Mailbox owners edit the same filters/autoresponder in the webmail under Settings (/mail/filters, /mail/autoresponder, User settings (webmail)) — one script per mailbox whoever edits it. A suspended or inactive mailbox cannot be logged into, so its filters cannot be pushed until it is re-enabled (502 with “login … refused”). Master-user login is refused when DOVECOT_MASTER_PASSWORD is unset in the API (502 “not set”). Scripts are capped at 64 kB and 100 rules, and one rule at 20 conditions and 10 actions (MAX_FILTER_RULES / MAX_FILTER_CONDITIONS / MAX_FILTER_ACTIONS in packages/core/src/sieve.ts). A migration import is held to the same 100 rules: above it the first 100 are imported and the rest named in the result (Migration → Limits, stated plainly). startsAt and endsAt are UTC instants (Z only; the API refuses an offset). The webmail’s day picker converts your local days into them. Users editing Sieve from a mail client via ManageSieve on :4190 (their own credentials) can create other scripts; saving from the admin UI re-activates mailserver and does not delete theirs. No per-domain default filters (per-mailbox spam settings are their own feature).