Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rules

Rules live in rules.toml next to config.toml. Postvak reads the file on every sync, and the daemon syncs every account within seconds of the file changing, whoever changed it. A file that fails to validate is rejected as a whole, and the previous rules stay active until the daemon stops or that account’s settings change. When there are none, no rules run and new mail notifies as the account’s notify setting says; once the file is fixed, rules that were already enabled also run on the mail that arrived meanwhile. Each account reports the error once, and syncing and actions carry on. postvak rules check validates the file; postvak rules test shows what each rule would do to the mail Postvak has cached.

[[rules]]
name = "github to folder"
match.header.List-Id = { contains = "github.com" }
actions.move = "Lists/GitHub"
actions.read = true

Rule keys

KeyDefaultMeaning
namerequiredUnique. Renaming a rule makes it a new rule.
accountevery accountOnly for this account.
folderINBOXThe folder the rule watches. INBOX matches in any case; other names match exactly.
enabledtruefalse skips the rule. Proposals start disabled.
proposed_bynoneSet by postvak rules propose, and removed when the rule is approved.
matchrequiredConditions that must all hold. At least one.
actionsrequiredWhat to do. At least one.

match and actions are both tables, so each key appears once, and the order you write them in does not matter. Write them as dotted keys, one per line, as the examples do, or as one inline table: actions = { move = "Lists", read = true }.

A tag is printable ASCII without spaces, backslashes or ( ) { % * " ], and $PostvakRestored is reserved. name, account, folder and move folders must not be blank, may not contain control characters, and are at most 255 characters. On a server that ignores case in folder names, such as Gmail or Exchange, a move to lists while Lists exists goes to Lists; a server that respects case, such as Dovecot, creates lists beside it.

Folder names are written in plain text, Archief/Één included; Postvak encodes them as IMAP requires (modified UTF-7) and shows the server’s folders decoded. A level of a folder or move folder (the parts between /) may not start or end with a space. A move folder also may not contain * or %, which IMAP reads as wildcards, or an empty level: /Lists, Lists//GitHub and Lists/ are refused. A name typed with separate accents, as macOS can produce, finds the folder the server spells with precomposed letters (Unicode NFC) and the other way round, and a new folder is created in NFC.

Like any invalid rule, one of these names stops the whole file from loading, so no rule runs until it is fixed; a file that loaded before Postvak checked them can stop after an upgrade, and postvak rules check names the rule. Postvak used to show non-ASCII folders in their encoded form, such as &AMkA6Q-n for Één. A rule that names a folder that way still finds the folder while the account has it, so it keeps watching and moving to Één; otherwise it means a folder with exactly that name. rules check warns about such a rule and gives the plain name to write instead.

Conditions

Text conditions take exactly one of:

  • contains: a substring.
  • equals: the whole value. On from, to and cc it also matches any single address in the field, so equals = "a@example.com" matches Alice <a@example.com>, b@example.com.
  • regex: Rust regex syntax. Start with (?-i) to make it case-sensitive.

All three ignore case. A text condition can also be { list = "name" } alone, for a text list in [lists] (see Lists).

contains and equals also take a list, which matches when any value does: subject = { contains = ["receipt", "invoice"] }.

KeyTakesMatches
from, to, cc, subjecttext conditionThat header.
from_nametext conditionA display name in From, without its quotes, never the address: "PayPal Service" <billing@random.example> has the name PayPal Service. A From without a display name never matches; with several addresses, any name may match.
bodytext conditionThe plain-text body; HTML mail is converted. Postvak downloads the body of new mail in the rule’s folder for this.
headera table of header names, each with a text conditionAny header, such as match.header.List-Id = { contains = "github" }, unfolded and with encoded words (=?UTF-8?…?=) decoded. Of a repeated header only the last copy, nearest the body, counts. Every header named must match.
from_domaindomain, or a list of them; { list = "name" } stands for a domain list’s domainsThe From address is in that domain or a subdomain of it: paypal.com matches service@paypal.com and x@mail.paypal.com, not x@notpaypal.com. Only the address counts, never the display name, and case does not matter. A list matches when any domain does.
to_domaindomain, or a list of themThe same, for any address in To; not Cc.
authenticatedtrue or falseYour provider vouches that the mail comes from its From domain. Needs the account’s trusted_authserv_ids (see Authenticated mail).
older_thanspan: 30min, 1h, 2d, 3w, 6mo, 1yMail that arrived at least this long ago. Units may be written out (2days); a bare m is refused, since it could mean minutes or months. Months and years follow the calendar.
younger_thanspan, as for older_thanMail that arrived less than this long ago: the opposite of older_than.
arrived_daysdays: mon-fri, sat,sun, fri-monMail that arrived on one of these days, in local time. Write a day by its first three letters or in full; a range such as fri-mon wraps past Sunday.
arrived_hourshours: 09:00-18:00, 22:00-07:00Mail that arrived between these local times, the end not included; an end before the start wraps past midnight. With arrived_days too, each is checked on its own: fri with 22:00-07:00 matches Friday 03:00, not Saturday 03:00.
larger_thansize: 500KB, 5MB, 1GBMail larger than this. A bare number is bytes, and 1KB is 1024 bytes.
has_attachmenttrue or falseMail with an attachment, or without one. An attachment is any named part that is not the body text, as search’s attachment: counts it: inline images with a file name and the attachments of a forwarded message count too.
attachmenttext conditionAn attachment’s file name; any attachment may match: attachment = { regex = '\.pdf$' }.
has_partMIME type, * allowed as the subtypeMail with a part of that type, at any depth, ignoring case: text/calendar for an invitation, application/* for any application part.
readtrue or falseRead or unread mail.
flaggedtrue or falseFlagged or unflagged mail.
to_metrue or falseYour address or an alias is among the recipients: To, Cc or Delivered-To. false catches list and bcc mail.
aliasaddress, * as wildcardAn address among the same recipients is that alias. * matches any run of characters; nothing else is special, so this is no regex.
tagIMAP keyword, such as $label1Mail carrying that keyword, ignoring case, including one a rule above it in the file just added. Thunderbird shows keywords as tags.
anylist of conditionsHolds when at least one entry holds.
nonelist of conditionsHolds when no entry holds.

Keys named after a header (from, from_name, to, cc, from_domain, to_domain) read that header alone; to_me and alias read every recipient.

has_attachment, attachment and has_part read the message’s structure, which the search index stores. For new mail in the rule’s folder Postvak fetches only the structure (IMAP BODYSTRUCTURE), not the message. Until the structure is known, a rule that reads it does not match, also through none. Mail indexed by an older version gets its structure in the background, at most a day after the upgrade.

Each entry of any and none is a set of conditions that must all hold, written like match itself, so none = [{ from = …, subject = … }] only excludes mail that matches both. To exclude either, give each its own entry. Entries can hold any and none again, up to eight levels deep. A rule whose conditions need the body never fires on a message whose body could not be downloaded, even through none.

Lists

When several rules need the same domains or the same pattern, name them once in [lists] and refer to them with { list = "name" }:

[lists]
kids = ["school.example", "daycare.example"]
security_subjects = { regex = "sign-?in alert|new device" }

[[rules]]
name = "kids to folder"
match.from_domain = { list = "kids" }
actions.move = "Kids"

[[rules]]
name = "flag important mail"
match.any = [{ from_domain = [{ list = "kids" }, "doctor.example"] }, { subject = { list = "security_subjects" } }]
actions.flagged = true
  • A list of domains goes in from_domain and to_domain, mixed with domains written in place if you like. A table with one of contains, equals or regex is a text list, for from, from_name, to, cc, subject, body, attachment and a header; there it stands alone, with nothing else beside list.
  • A name [lists] does not have, or a list used where the other kind belongs, makes the file invalid, as does a malformed list that no rule uses yet. A list cannot refer to another list.
  • Changing a list changes every rule that uses it, and restarts each one’s clock (see When rules act).
  • rules list --json and the MCP rules_list tool show the reference in match and, under lists, the values of each list the rule uses. rules coverage and senders name the lists beside the rules that match.
  • The window shows a reference by name, such as subject in list security_subjects. In the rule dialog a text or domain condition can be in list, picking one of the lists of its kind, with the list’s values under the row; change the values in rules.toml. A domain list mixed with domains in place is shown as it is and kept on save.
  • postvak rules lists (MCP rules_lists) shows every list with its values, the rules that use it and any pending change; --json gives one row per list with name, values, used_by and pending.

An agent changes lists the way it changes rules, by proposal, since one list change changes every rule that uses it. rules propose takes {"lists": {"kids": ["school.example", "club.example"]}}, alone or beside "rules", and stores each list as a pending change after the rules, which changes nothing until approved:

[[list_proposals]]
name = "kids"
proposed_by = "mcp:claude"
value = ["school.example", "club.example"]

A change is refused when it would break a rule that uses the list (a domain list turned into a text list, say), when it leaves the list as it is, and while the list has another proposer’s pending change; replace swaps your own. A proposed rule may refer only to lists already approved. rules test --list kids shows what approving would do: for each enabled rule that uses the list, the cached messages it matches now and after, and how many it gains and loses. rules approve --list kids writes the change into [lists] and restarts the clock of every rule that uses it; rules reject --list kids drops it, and rules withdraw --list kids --by WHO takes back your own. rules coverage --include-pending counts pending list changes as approved. The window shows a list change among the proposals, with the list’s line before and after and its dry run, and a new one brings the same notification as a new rule.

Authenticated mail

Anyone can write any From address. A receiving mail server checks the sender’s DKIM signature, SPF and DMARC, and writes its verdict in an Authentication-Results header at the top of the message. authenticated = true holds when that verdict says DMARC passed for the From domain. When it gives no DMARC pass or fail for that domain, DKIM passing for a domain aligned with the From domain is enough (the same domain, or a parent or subdomain of it within the same organisation, so a mail.paypal.com signature counts for paypal.com, and a sendgrid.net one does not); a DMARC fail always wins, since the domain’s own policy may demand a closer match. SPF alone is not enough, since it vouches for the envelope sender, not the From address. A From with no address or with several is never authenticated.

A sender can write an Authentication-Results header too, so Postvak only reads the ones written by servers you list in the account’s trusted_authserv_ids (see Accounts), recognised by the server name at their start (the authserv-id), and of those only the topmost, the one the last server added. To find the name, run postvak headers UID on a message you received, which shows its headers topmost first, and take the name after Authentication-Results: in the topmost one, such as mx.google.com for Gmail. List only a server that writes the header on every message it receives: on mail it left without one, the topmost header with its name would be the sender’s. Postvak does not guess the name from the account’s IMAP host for that reason. It does read the mail: when the topmost header of every one of the newest 20 messages in an account’s INBOX (and at least 10 of them) names the same server, the warning below offers that server, with the command that lists it, postvak account trust ACCOUNT SERVER; the window’s Rules view offers it as a button. Nothing is listed until you run the command or press the button. Without a list, no mail is authenticated, so authenticated = true never holds and authenticated = false holds for all mail; rules check warns about a rule that uses authenticated for such an account, and rules test, the MCP rules_test tool and the window’s preview warn the same way about the rule they preview, before it is stored. Mail without a header from a listed server is not authenticated, so a rule with authenticated = false also catches it.

Use it with from_domain on rules that move mail you would act on, such as security alerts or invoices, so that mail which only claims to come from that domain stays in your inbox:

[[rules]]
name = "paypal"
match.from_domain = ["paypal.com", "paypal.nl"]
match.authenticated = true
actions.move = "Finance"

With from_name it catches display-name spoofing, mail whose name claims a sender its address does not back:

[lists]
bank_names = { contains = ["paypal", "ing bank"] }
banks = ["paypal.com", "ing.nl"]

[[rules]]
name = "display name says a bank, the address doesn't"
match.from_name = { list = "bank_names" }
match.none = [{ from_domain = { list = "banks" }, authenticated = true }]
actions.tag = "$Suspicious"
actions.flagged = true

Actions

KeyEffect
delete = trueSaves the message as .eml in the local trash, then removes it from the server. Later rules don’t run for that message.
archive = trueMoves it to the server’s Archive folder. Fails when the server has none. A rule cannot both archive and move.
junk = trueReports it as junk, as the Junk button does: tags it $Junk, which trains the server’s spam filter, and moves it to the server’s Junk folder. Fails when the server has none. Only a delete wins over it; the rules’ other actions don’t run for that message. A rule cannot both junk and move or archive.
move = "Folder/Sub"Moves it to that folder, creating the folder if needed and subscribing to it, so mail apps that show only subscribed folders list it. When the server refuses to create it and the move then fails, the error says both. A folder that only groups others and cannot hold mail, such as Gmail’s [Gmail], is refused. / separates levels on most servers; see the warnings below for servers that use .. {subaddress} in the folder is the +tag of the address the message was sent to, lower-cased: see Give each +tag its own folder.
read = trueMarks it read; false marks it unread.
flagged = trueFlags it; false removes the flag.
tag = "$label1"Adds that IMAP keyword, or each keyword of a list (tag = ["$label1", "$work"]), which Thunderbird and other clients show as tags. The server must accept custom keywords.
notify = trueNotifies even when the message was moved; false never notifies.
stop = trueLater rules don’t run for that message.
forward = { to = "books@example.org" }Sends the message on to other addresses through the account’s SMTP server, before the rule’s other actions run. Only new mail, and only once you approved the rule in Postvak: see Forwarding.
after = "15min"The rule’s other actions wait: for a span after the message arrived (30min, 1h, 2d, as for older_than), until it is read ("read"), or for the first of a list (after = ["read", "2d"]). Meanwhile the rule’s stop and notify already hold, so stop keeps the rules below from acting on the message while it waits; without stop they run as if this rule had not matched yet. A waiting move does not count as a move, so the message still notifies. A rule needs an action to wait with, such as move or delete. rules apply-existing acts on older mail whose wait is over; older mail still waiting is left alone, then and later, since the rule only acts on mail that arrives after it was enabled.

The order of the keys does not matter: flags and tags are set before a move. When several rules match a message, the first one in the file decides each thing only one rule can decide: a delete in any matching rule wins over everything else, then a junk, then the first move or archive runs, and the first notify says whether it notifies. After a rule with stop, later rules in the file don’t run for that message; earlier ones already did.

A rule sees what the matching rules above it changed: read, flagged and tag test the message as those rules left it, in the same sync, not only once the change is back from the server. So one rule can tag mail and a later one route on that tag, as in none = [{ tag = "Security" }] after a rule that tags security alerts. A rule below never changes what a rule above it saw.

When rules act

  • On every sync, in file order. Because rules run again on each sync, older_than, younger_than and read can fire or stop firing later, for example an hour after you read a sign-in code, and actions waiting for after run on the first sync once it has come: a full sync runs every sync_interval_secs (120 seconds unless the account sets it), so up to that much later. To see what such a rule will do later, preview it as of that time: postvak rules test --as-of 1h or --as-of 2026-11-01T14:00 (and rules coverage --as-of). Only the clock moves; the mail and whether it is read, flagged or tagged stay as they are now. To see what a hold or an after wait does to new mail, add --as-arrival: every stored message counts as arrived just now, so --as-arrival shows a younger_than = "15min" hold catching it and --as-arrival --as-of 20min shows it let go.
  • A rule acts only on mail that arrived after the rule was enabled, so adding a rule never touches your history. postvak rules apply-existing NAME is the explicit opt-in. Run it with --dry-run first. When the rule would delete mail from the server for good (delete), it prints how many messages per account and how long their .eml backups are kept, then asks you to type yes; without a terminal it refuses unless you pass --yes. Moves and flags run without asking.
  • A rule approved with postvak rules approve acts on mail that arrives after the approval. A rule you add or enable by editing the file acts on mail that arrives after the next sync picks it up.
  • Renaming a rule, editing its account, folder, match or actions, changing a list it uses, or disabling and enabling it again, restarts that clock.
  • Rules move a message out of a folder once. When it comes back, because you moved it back or a rule on another machine did, no rule moves, archives or junks it again; the other actions still run. So two rules that move mail between two folders stop after one round. Postvak tells messages apart by Message-ID and arrival time, which servers keep when mail moves.
  • Mail restored with postvak trash restore carries the $PostvakRestored keyword. Rules never act on it again. This needs a server that accepts custom keywords; without one, the same rule can delete restored mail again.

Forwarding

forward passes matching mail on, such as invoices to your bookkeeper or what reaches a shared address to your own:

[[rules]]
name = "invoices"
match.from = { contains = "billing@" }
match.has_part = "application/pdf"
actions.archive = true
actions.forward = { to = ["books@example.org"], note = "Invoice for the records", attachments = { type = "application/pdf" } }
KeyEffect
toOne address or a list; required. cc and bcc take the same.
fromYour address to send as: the account’s, or one an alias matches. It needs the rule’s account, so rules check refuses it on a rule for every account. Without it the forward goes from the address of yours the message was sent to, else the account’s.
subject_prefixPut before the subject, Fwd: unless set; may be empty, and {subaddress} works as in move. Not added when the subject already starts with a forward prefix.
noteText above the forwarded message.
asinline (the default): the original’s text below a header block, its attachments attached again. attachment: the original whole, untouched, as an attached message.
attachmentsWhich attachments an inline forward takes: true (the default) all, false none, or a table naming them with type (application/pdf, image/*), name (a text condition on the file name) and max_size (5MB). Those left out are named under the forwarded message: Not forwarded: big.zip (40 MB). Refused with as = "attachment".
max_sizeFor a message larger than this, max_size = "10MB" does not forward it, and max_size = { limit = "10MB", over = "strip" } forwards it without its attachments. The default limit is the server’s own, else 25MB. Its other actions run either way.
rateForwards per hour, at most; 20 unless set.

A forward rule acts on new mail only. It never sends mail the rule matched before you approved it, and rules apply-existing, the window’s Apply to existing mail, rules test and coverage leave its forward out and show it as forward:books@example.org (new mail only); the rule’s other actions run there as usual.

Approval. No forward rule sends anything until you approved it in Postvak: with postvak rules approve NAME, the Approve button in the Rules view, which shows its addresses, or by saving it in the window’s rule editor, where you just wrote them. The approval is the rule’s whole definition, kept in each account’s own store and never in rules.toml, so any change to the rule, its match, folder, recipients, note or rate, needs a new approval; approving starts the rule’s clock again, so it forwards only mail that arrives from then on. A rule written in rules.toml by hand, or by an agent, waits. While it waits, none of its actions run, so it never archives mail it did not forward. The daemon raises the notification proposals use (“A rule waits for your approval”), postvak rules list shows it waiting for approval in work, rules check says how to approve it, and the Rules view shows it above the rules with Approve. An account added later starts unapproved, so the rule waits there until you approve it again. A forward rule also waits, without notifying, in an account where sending is not set up: waiting: sending not set up.

Limits. Each message is forwarded by a rule once, even when it is moved back or synced again. Every forward carries X-Postvak-Forwarded: <your address> and keeps the ones the message had; a message is not forwarded when one of those names an address of the account, when it carries three already, or when it is an automatic reply (Auto-Submitted: auto-replied), so a chain such as shared address → own account → bookkeeper works and a loop stops at the first repeat. Its other actions still run. A rule that would forward more than its rate in an hour stops forwarding, gets its other actions done, and waits for your approval again; a notification says Forwarding paused for "invoices": more than 20 in an hour.

Failures. A forward goes through the outbox at once, without the undo delay. When it cannot be queued, because the message cannot be downloaded or from is not yours, none of the rule’s actions run on that message, and the next sync tries again; rules check and the Rules view show a failure that lasts. A forward the server refuses stays in postvak outbox with the reason. Each forward is a row in postvak log, forward:books@example.org (+1); undo cannot call it back, says so, and undoes the rule’s other actions.

Notifications

New INBOX mail notifies unless a rule moved, archived, junked or deleted it, or a matching rule says notify = false; marking, flagging and tagging still notify. notify = true forces a notification for moved mail. When several matching rules set notify, the first one wins. Only INBOX mail notifies, so notify on a rule for another folder does nothing. With notify = false on the account, only rules that say notify = true notify. Deleted mail, and mail found by the first sync of a folder, never notifies.

On Linux a notification shows the sender and the subject with Postvak’s icon, and asks the desktop for its new-mail sound, which follows Do Not Disturb and your volume; your desktop’s notification settings list Postvak, so you can turn the sound or the popups off there. When one sync finds more than three new messages for an account, they come as one notification (“12 new messages”, naming the senders), which opens the newest. Plasma shows the account beside Postvak’s name. Clicking a notification opens the message in the window, which starts if it is not open. On Wayland an open window cannot bring itself to the front, so Plasma highlights it on the task bar and GNOME says “Postvak is ready”.

On macOS a notification shows the sender and the subject as Postvak’s once Postvak.app is installed (the DMG or brew install --cask postvak); with only the postvak command installed, macOS shows them under its default sender. A new-mail notification plays the system’s notification sound, which follows Focus and your volume. Bursts of new mail come as one notification, as on Linux. Clicking a notification opens the message in the window, which starts if it is not open. Notification Center keeps notifications until you clear them, so only the latest 64 open their message; a click on an older one just brings Postvak to the front. System Settings › Notifications › Postvak turns the sound or the banners off.

Examples

Delete sign-in codes

Delete sign-in codes and magic links an hour after you read them:

[[rules]]
name = "purge sign-in codes"
match.from = { regex = "no-?reply@" }
match.subject = { regex = "sign.?in|verification code|magic link" }
match.older_than = "1h"
match.read = true
actions.delete = true

Move list mail out of the inbox

Move list mail that is not addressed to you, without a notification:

[[rules]]
name = "list mail"
match.to_me = false
match.header.List-Unsubscribe = { regex = "." }
actions.move = "Lists"
actions.notify = false

Give an alias its own folder

Give a shop alias its own folder, but still notify:

[[rules]]
name = "shop alias"
match.alias = "*@shop.example.com"
actions.move = "Shopping"
actions.notify = true

Give each +tag its own folder

Sort mail sent to me+github@example.com, me+shop@example.com and any other tag into a folder per tag, without a rule for each:

[[rules]]
name = "plus addresses to folders"
match.alias = "me+*@example.com"
actions.move = "Tags/{subaddress}"

{subaddress} is the part after + of the first recipient (Delivered-To, which your server writes, then To and Cc) with a usable tag that is the rule’s alias, or without one, your own address or an alias with or without a tag. It is lower-cased, so me+GitHub@ and me+github@ share Tags/github, and a folder that does not exist yet is created. Anyone can write to any tag, so only a tag of up to 64 ASCII letters, digits, - and _ counts: a message without one, or with another tag such as me+a.b@, is not moved, and the rule’s other actions still run. {subaddress} must sit below a folder you name, as in Tags/{subaddress}, so a tag such as trash cannot reach a folder of your own. rules test shows the folder each message would go to.

Move shop mail after notifying

Leave shop mail in INBOX for 15 minutes, or until you read it, before moving it, so mail apps on your other devices, which mostly notify only for INBOX, see it first. Postvak notifies too, since nothing moved the message when it arrived. The move runs on the first full sync after that, so up to sync_interval_secs (120 seconds by default) later:

[[rules]]
name = "shop mail, after notifying"
match.from = { contains = "shop.example.com" }
actions.move = "Shopping"
actions.after = ["read", "15min"]

Leave important mail in INBOX for your phone

When a later rule moves everything else, give the rule for the mail you want your phone to notify about after and stop. While it waits, stop keeps the rules below it from moving the message; after 15 minutes the rule moves it itself:

[[rules]]
name = "important mail, after the phone saw it"
match.any = [
  { from_domain = ["bank.example", "work.example"] },
  { subject = { regex = "sign-?in alert|new device" } },
]
actions.move = "Important"
actions.after = "15min"
actions.stop = true

To hold mail back for the rules below to decide on later, rather than act on it yourself, put a rule with younger_than = "15min" and stop = true near the top of the file instead. That holds every rule below it for those 15 minutes, the ones that only tag, flag or mark read too, so another rule or mail app routing on such a tag sees the message without it until then. To delay only the moves, give each moving rule its own after instead.

Tag shop mail except receipts

Tag shop mail as Important (Thunderbird’s $label1), except receipts and invoices:

[[rules]]
name = "tag shop mail"
match.from = { contains = "shop.example.com" }
match.none = [{ subject = { contains = ["receipt", "invoice"] } }]
actions.tag = "$label1"

Flag certain people or topics

Flag mail from either of two people, or about an outage:

[[rules]]
name = "flag the important ones"
match.any = [
  { from = { equals = ["alice@example.com", "bob@example.com"] } },
  { subject = { contains = ["outage", "incident"] } },
]
actions.flagged = true

Set large mail aside

Move large mail you have not flagged to its own folder, and leave it to no other rule:

[[rules]]
name = "large mail"
match.larger_than = "10MB"
match.flagged = false
actions.move = "Large"
actions.stop = true

File invoices that come as a PDF

Invoices with a PDF go to Finance, while “your invoice is ready, log in to view it” mail stays. Invitations stay in INBOX, and archives or programs from a sender your provider does not vouch for are flagged:

[[rules]]
name = "pdf invoices"
match.subject = { contains = "invoice" }
match.attachment = { regex = '\.pdf$' }
match.none = [{ has_part = "text/calendar" }]
actions.move = "Finance"

[[rules]]
name = "unvouched archives"
match.attachment = { regex = '\.(exe|zip|iso)$' }
match.authenticated = false
actions.flagged = true

Archive read mail after 30 days

[[rules]]
name = "archive old read mail"
match.read = true
match.older_than = "30d"
actions.archive = true

Quiet work mail after hours

Work mail that arrives in the evening or at the weekend stays in INBOX without a notification:

[[rules]]
name = "quiet work mail after hours"
match.from_domain = "work.example"
match.any = [{ arrived_days = "sat-sun" }, { none = [{ arrived_hours = "08:00-18:00" }] }]
actions.notify = false

Local time is the time zone of the machine the daemon runs on. The Docker image has no time zone data and runs on UTC: mount the host’s with -v /etc/localtime:/etc/localtime:ro.

Tag spam by its score

Filter junk on your provider’s spam score. Many servers score mail but only move it to Junk above a high threshold; SpamAssassin writes the score as stars in X-Spam-Level, and some providers use their own header, such as X-Soverin-Spam-Level. Look at the headers of a few spam and legitimate messages with postvak headers UID to find yours, tag first to see what a threshold catches, then change the action once it only catches spam:

[[rules]]
name = "spam suspects"
match.header.X-Spam-Level = { regex = '^\*{2,}' }
actions.tag = "$SpamSuspect"
[[rules]]
name = "spam"
match.header.X-Spam-Level = { regex = '^\*{5,}' }
actions.junk = true

junk finds the account’s Junk folder whatever the server calls it, such as [Gmail]/Spam, and tags the mail $Junk so the server’s filter learns from it, as reporting it by hand does.

Sharing rules between devices

rules.toml is a plain file, so a file sync such as Syncthing can keep it the same on every device; the daemon picks up a synced change within seconds. Sync that file alone: config.toml holds settings for this device, such as a password = { command = "..." }, and on macOS the mail stores live in the same folder. With Syncthing, share the folder that holds rules.toml (see Accounts; ~/.var/app/io.github.postvak_app.postvak/config/postvak/ for the Flatpak) and give it this .stignore:

!/rules.toml
*

A symlink from that folder to a rules.toml elsewhere works outside the Flatpak too, since Postvak writes through it; Syncthing does not follow symlinks, so the real file has to be in the synced folder. The Flatpak cannot reach a file outside its sandbox, so share its own folder instead.

Every device that runs Postvak runs the rules itself. Moving or marking mail that another device already handled does nothing, but a deleted message’s .eml backup is only on the device that deleted it.

When two devices change the file before they sync, the sync keeps one version and leaves the other beside it, such as rules.sync-conflict-20261024-101500-ABCDEFG.toml from Syncthing or rules (conflicted copy 2026-10-24 101500).toml from Nextcloud. Postvak ignores that copy, so its changes are lost until you move them into rules.toml. postvak rules check, the Rules view, the MCP rules_check tool and the daemon’s log warn about such a copy, and about a rule whose account is not set up on this device, since it never runs there; name each account the same on every device. They also warn about a rule that moves mail to the folder it watches (INBOX in any case), since that move does nothing, and about a rule that uses authenticated for an account without trusted_authserv_ids, since no mail is authenticated there, naming the server its INBOX offers (see Authenticated mail). They warn about a rule watching a folder that none of the accounts it is for syncs, such as a misspelled name, once each of those accounts has finished a pass through all its folders without errors; a rule for every account stays quiet while one account syncs the folder. Once an account has finished such a pass, they also warn about a move to a folder it does not have that is named like a special one (Trash, Junk, Spam, Archive, Sent, Drafts) while the account keeps that role in another folder, such as [Gmail]/Trash: move to that folder instead, or use archive for the archive and junk for the junk folder. Once an account has synced, they warn about a move folder that account does not have yet and that does not fit its server: one with / on a server that separates levels with another character, such as . on Courier, where it would become one folder with / in its name, and a top-level name on a server that keeps every folder under INBOX., where creating it usually fails. Neither is an error, since the same file can serve accounts on other servers.

Proposals

Agents never edit rules.toml. They run postvak rules propose, which appends the rule with enabled = false and proposed_by set. It takes one rule or several at once, stored in the order given or not at all, and with --before NAME or --after NAME puts them next to that rule instead of at the end, since order decides which rule runs first. An agent can take back its own pending proposal with postvak rules withdraw NAME --by WHO, or change it with rules propose --replace; neither touches an approved rule or another proposer’s.

To change a rule that is already there, an agent runs rules propose --edit with the rule written as it should read, under its own name. The change is stored right after the rule as a disabled rule named <name> (edit), with replaces = "<name>", and the rule runs unchanged until you approve. Approving writes the change over the rule, which keeps its comments, place and on or off state, removes the proposal, and restarts the rule’s clock so it acts as changed only on mail that arrives from then on. A rule has one pending edit at a time. If the rule is renamed or removed in the meantime, the edit can no longer be approved and rules check warns about it; reject it. In the window, an edit’s card shows the rule’s lines as a diff: - for what goes, + for what comes.

To review a proposal:

  • postvak rules list shows every rule in file order, the order they run in, with its state and proposer; a pending proposal is off with a proposer, and an edit adds which rule it edits, then how many messages the rule acted on in the last 30 days. A forward rule that waits adds why and where: waiting for approval in work. --json adds each rule’s whole definition, the values of the lists it uses, its 0-based position in the file, hits_30d (those messages, each counted once, over every account), last_fired (when it last acted, as Unix seconds, or null if never) and forward_waiting (the accounts where a forward rule waits, empty when it waits nowhere). Actions you took yourself and runs of rules apply-existing are not counted.
  • postvak rules test NAME previews what a proposal would do.
  • postvak rules coverage --include-pending shows what the proposals change for a folder as a whole; see Coverage.
  • postvak rules approve NAME enables it, or for an edit writes it over the rule it edits; for a rule that forwards, it approves the forward too, and on a forward rule already enabled it approves the forward alone.
  • postvak rules reject NAME removes it.

While the daemon runs, a new proposal brings a desktop notification (“A rule waits for your approval”, naming it; several within two seconds come as one), on Linux and macOS alike. Clicking it opens the window on Rules, as postvak gui --rules does. Proposals already waiting when the daemon starts do not notify again, and neither does a proposal changed with --replace, which keeps its name.

postvak rules move NAME --before OTHER or --after OTHER changes when a rule runs, keeping the file’s comments; the comments right above the rule move with it, and so does a pending edit of it, which stays right after it. The Rules view does the same when you drag a row, and the MCP rules_move tool for an agent granted rules:write.

Coverage

postvak rules coverage --folder INBOX runs every enabled rule over the mail Postvak has stored for a folder, the way rules test does, and changes nothing. It counts the mail that a rule matches, that no rule matches, and that several rules match, which it calls contested. For mail that no rule matches, it lists the senders who send the most. For each contested message, it lists every rule that matches, then what would run: a delete on its own, or else every flag change and only the first move. A broad rule early in the file that takes mail meant for a later rule shows up here. --include-pending counts the proposals still waiting for approval as if they were on, each edit in place of the rule it edits. --group-by domain groups the senders by domain, and --limit (20 by default) caps the senders and contested messages shown. The counts always cover the whole folder.

postvak senders --folder INBOX groups a folder’s mail by sender, or with --group-by domain by domain, the biggest group first. Each group shows its message and unread counts, its first and last date, a few subjects, and the enabled rules that already match some of it. mail.shop.co.uk and shop.co.uk count as one domain. --after and --before take a date such as 2026-01-15, or 30d, 3w, 6mo or 1y back from now.

Neither command has a place in the window yet; both are also MCP tools, rules_coverage and senders.

postvak rules schema prints the JSON Schema of this file, and rules.schema.json in these docs holds the same schema.