Spam and malware filtering
How Nixt Server filters incoming and outgoing mail — the pipeline, scores and thresholds, the attachment policy, rules, ClamAV and milters such as Rspamd.
Every message that arrives on port 25 or through submission passes through the filter pipeline before the server accepts it. The pipeline runs while the sender is still connected, so a message the filter refuses is refused to the sender, never accepted and bounced later.
The pipeline
The stages run in this order. The first stage to reach a decision ends the run; if none does, the total score decides.
| Order | Stage | Always on | What it does |
|---|---|---|---|
| 1 | auth | Yes | Turns the SPF, DKIM, DMARC and reverse DNS results into a score, and adds the Authentication-Results header. |
| 2 | senders | Yes | The organisation’s allowed and blocked senders. |
| 3 | history | Unless turned off | What your people have made of mail from the same proved sender before. See Sender history. |
| 4 | impersonation | Yes | Names and domains that imitate your people, your domains and your partners’. See Impersonation. |
| 5 | reputation | When [[filter.blocklists]] names a list | Looks the sender and the message’s links up in DNS blocklists. |
| 6 | malware | Yes | The attachment policy. Refuses executables, dangerous file types, archive bombs, encrypted archives and Office files with macros. |
| 7 | smime | Yes | Checks the S/MIME signature on a signed message and records what it found, without scoring it. See S/MIME signatures. |
| 8 | pgp | Yes | Checks the OpenPGP signature on a signed message and records what it found, without scoring it. See OpenPGP signatures. |
| 9 | qr | Yes | Links hidden in pictures as QR codes. See QR codes. |
| 10 | domain_age | Unless turned off | How recently each link’s domain was registered. See How new a link’s domain is. |
| 11 | rules | Yes | The server’s rules, then the tenant’s. See Rules. |
| 12 | classify | Unless turned off | What your people taught it by moving mail into and out of Junk. See The classifier. |
| 13 | fuzzy | Yes | Near-copies of mail your people reported. See Near-duplicates of reported mail. |
| 14 | greylist | When [filter.greylist] is set | Asks a first-time sender whose mail scores badly to try again later. |
| 15 | clamav | When [filter.clamav] is set | Scans the whole message with ClamAV. |
| 16 | milter | For each [[filter.milters]] entry, in the order written | Asks an external filter such as Rspamd. |
versealx-server explain prints the pipeline as a node will run it:
the filter pipeline, in order:
auth SPF, DKIM, DMARC, as score and headers
senders the organisation's allowed and blocked senders
history what the tenant's people made of the proved sender's mail before
impersonation names and domains imitating the tenant's people and domains
malware the attachment policy
smime S/MIME signatures verified, as evidence only
pgp OpenPGP signatures verified, as evidence only
qr links hidden in pictures as QR codes
domain_age how new each link's domain is, asked of its registry over RDAP
rules the operator's rules, then the tenant's (the API's /rules)
classify what the tenant's people taught it by moving mail into and out of Junk
fuzzy near-copies of mail the tenant's people reported
clamav clamd at /run/clamav/clamd.ctl
milter `rspamd` at 127.0.0.1:11332
Time budget
The scoring stages — senders, history, impersonation, reputation, qr, classify and fuzzy — share a budget of 15 seconds per message while the sender waits. Change it with budget_seconds in [filter]: at most 300, and 0 leaves all the scoring to delivery.
When a scoring stage does not finish in time, it and the scoring stages after it are left for delivery. The note BUDGET_EXHAUSTED is added to the evidence, and the message is accepted on what the other stages found. Delivery runs the stages that were left before anybody can read the message, with a minute for them, and their verdict files it as it would have been filed at the door: into Junk for a tag, into Quarantine for a quarantine or a refusal. The X-Versealx-Filter header is replaced with the finished one, and the message trace says which stages were left and what delivery decided.
The budget never cuts rules, auth, malware, greylist, clamav or a milter. Each runs before the message is accepted, however long the scoring took, under its own time limit, so no message is accepted without the scanner and the milters having seen it. A scanner or milter that cannot be reached is handled as its on_unavailable says; one that hangs past five minutes defers the message with 451, rather than letting it through.
The same holds for mail your people send: what the budget left of a submitted message is finished before it leaves the queue, and a message the finished stages would have refused is held for review instead of sent, with the sender told it was not sent.
vsx_filter_stages_cut_total{stage} counts the stages the budget cut. A stage that shows up there often is slow for everybody: a blocklist that answers slowly, or a DNS resolver under strain. A scanner or milter, which the budget never cuts, that takes more than half the budget is counted in vsx_filter_stages_slow_total{stage}; versealx-server doctor warns of the last hour’s slow runs, and the server’s slow-scanner alert tells you when there are many.
Scanning stored mail again
New scanner signatures catch what was clean when it arrived. An organisation’s administrators, or the operator for any organisation, can scan the mail already in mailboxes again, received in a date range:
versealx-server admin filter rescan --since 2026-09-01
versealx-server admin filter rescan --since 2026-09-01 --until 2026-09-30 --what-if
versealx-server admin filter rescan-status
versealx-server admin filter rescan-stop
On the console, Scan again under Protection counts first for the dates chosen, then starts the scan when you confirm, and shows its progress with Stop. Through the API, POST /tenants/{tenant}/rescan starts one with since, until and whatIf (dates as YYYY-MM-DD, UTC), GET says how far it has got, and DELETE stops it.
Each message runs through today’s filter as it would at delivery, except the stages that would mean something different now: authentication keeps its result from when the message arrived, and greylisting, sender reputation, domain age, sender history and milters are not asked again. Malware or phishing found is moved from the mailbox to quarantine, saying the rescan found it, and can be released from there. A message that only scores as spam is marked as junk where it is, so nobody loses mail they have already read. Nothing is deleted; --what-if only counts.
A rescan goes at a steady pace so delivery is never slowed, one at a time per organisation, and carries on where it was after a restart. Starting and stopping it, and each message moved, are in the audit log.
Scores and thresholds
The auth stage adds to the score:
| Finding | Tag | Score |
|---|---|---|
| SPF failed | SPF_FAIL | +2.0 |
| SPF soft-failed | SPF_SOFTFAIL | +1.0 |
| SPF passed | SPF_PASS | 0 |
| No DKIM signature | DKIM_NONE | +0.5 |
| DKIM signatures present, none verified | DKIM_FAIL | +2.0 |
| DKIM verified | DKIM_PASS | 0 |
| DMARC failed | DMARC_FAIL | +3.0 |
| DMARC passed | DMARC_PASS | −1.5 |
| Reverse DNS did not confirm | IPREV_FAIL | +0.5 |
When no stage decides, the total score picks the outcome:
| Score | Outcome for incoming mail |
|---|---|
| 15 or more | Refuse: 550 5.7.1 message scored <score>; refused as spam |
| 8 or more | Quarantine: stored in the hidden Quarantine mailbox |
| 5 or more | Tag: delivered to Junk, marked as junk, with [SPAM] at the front of its subject |
| Less than 5 | Deliver |
A message that fails its sender’s DMARC reject policy is refused before the pipeline runs; see Receiving mail.
The X-Versealx-Filter header
Each delivered message carries a header summarising the evidence, for example:
X-Versealx-Filter: score=1.0 SPF_PASS(+0.0) DKIM_FAIL(+2.0) DMARC_PASS(-1.5) IPREV_FAIL(+0.5) MILTER_RSPAMD_ACCEPT(+0.0) decided-by=thresholds
It lists every tag with its score, and ends with decided-by= and the stage that decided where the message goes (thresholds when it was the spam score), so you can see why a message landed where it did.
The attachment policy
The malware stage looks at every attachment. The first problem found refuses the message with 550 5.7.1 attachment refused by policy: <detail>.
| Check | Tag | Example detail |
|---|---|---|
| A file type that is not accepted, by name | ATTACHMENT_BLOCKED_TYPE | invoice.js: .js is not accepted |
| A program disguised with another name | ATTACHMENT_EXECUTABLE_DISGUISED | report.pdf is a Pe executable |
| An Office document or other file carrying VBA macros | ATTACHMENT_MACROS | budget.xlsm carries a VBA project |
| An archive that would unpack to more than 2 GiB, or more than 200 times its packed size, or holds more than 10,000 entries | ATTACHMENT_ARCHIVE_BOMB | files.zip: 9000000000 bytes declared from 42000 |
| An archive with encrypted entries, which no scanner can look into | ATTACHMENT_ARCHIVE_ENCRYPTED | private.zip: encrypted entries cannot be scanned |
| Archives nested more than 3 deep | ATTACHMENT_ARCHIVE_NESTED | a.zip: archives nested 4 deep |
| A blocked file type inside an archive | ATTACHMENT_BLOCKED_TYPE | photos.zip: contains setup.exe (.exe) |
| A ZIP file with no readable directory | ATTACHMENT_ARCHIVE_UNREADABLE | broken.zip: no central directory |
Blocked file types: .exe, .scr, .com, .pif, .bat, .cmd, .vbs, .vbe, .js, .jse, .wsf, .wsh, .ps1, .psm1, .hta, .cpl, .msi, .msp, .jar, .lnk, .reg, .iso, .img, .vhd, .chm, .application, .gadget, .inf, .scf, .url, .dll, .sys.
Programs are recognised by their contents — Windows, Linux and macOS executables — whatever the file is called. Archives are examined through their directory, and one level inside each archive is unpacked, up to 8 MiB, to look further.
Rules
Rules let you act on mail the built-in stages let through: quarantine a phishing campaign by its subject, score mail from a partner that fails DKIM, tag newsletters into Junk, or add a header for a system downstream. Each tenant has its own list of rules, and tenant 0 holds the server’s rules, which run first for every tenant.
A rule has an id (letters, digits, - and _), a name, a list of conditions and a list of actions:
{
"id": "fake-invoices",
"name": "Fake invoices",
"conditions": [
{"subject": {"contains": "invoice"}},
{"not": {"dkim": "pass"}},
{"attachmentExtension": "exe"}
],
"actions": [{"quarantine": {"reason": "an invoice carrying a program"}}]
}
Rules run in the order listed. A rule applies when all its conditions hold; set "all": false for any. Set "enabled": false to keep a rule without running it.
Conditions
Text is compared with {"contains": "…"} or {"is": "…"}, both ignoring case, or {"matches": "…"}, a regular expression of at most 1,024 characters.
| Condition | Holds when |
|---|---|
{"subject": <text>} | The subject matches. |
{"from": <text>} | The From: address matches. |
{"mailFrom": <text>} | The envelope sender matches. |
{"recipient": <text>} | Any envelope recipient matches. |
{"header": {"name": "…", "matches": <text>}} | A header with that name matches. |
{"body": <text>} | The text of the message matches. |
{"sizeOver": 10485760} | The message is at least this many bytes. |
{"attachmentExtension": "exe"} | An attachment has this extension (lower case, no dot). |
{"attachmentType": "application/zip"} | An attachment has this content type. |
{"spf": "pass"}, {"dkim": …}, {"dmarc": …} | The result is pass, fail or none. |
{"scoreOver": 5.0} | The score so far is at least this. |
{"evidence": "TAG"} | An earlier stage added this tag. |
{"clientIn": "192.0.2.0/24"} | The connecting address is in this range. |
"outbound" | The message is being sent by a signed-in person. |
"bounce" | The envelope sender is empty. |
{"senderLocation": "inside"} | The sender is one of the organisation’s own people, sending signed in; "outside" for anybody else, another organisation on this server included. |
{"recipientLocation": "outside"} | Any envelope recipient is outside the organisation; "inside" for one of its own. |
{"senderMemberOf": "finance@acme.example"} | The sender is a member of this group, however deep its groups nest. Mail from outside is nobody’s member. |
{"recipientMemberOf": "sales@acme.example"} | Any envelope recipient is this group or one of its members. |
{"tls": true} | The message came over TLS; false for one that came in the clear. |
{"attachmentOver": 10485760} | An attachment, decoded, is at least this many bytes. |
{"containsSensitive": {"detectors": ["card-number"], "atLeast": 5}} | The message’s text holds at least this many of these sensitive numbers, together. atLeast is 1 if left out. |
{"not": <condition>} | The condition does not hold. |
Sensitive numbers
containsSensitive finds numbers that data-protection rules name, in the subject, the message text and its attachments, each checked the way its issuer checks it, so an order number or a barcode isn’t mistaken for one:
| Detector | Finds |
|---|---|
card-number | Payment card numbers of the major issuers, by the Luhn check and the issuer’s ranges and lengths |
iban | IBANs, by their country’s length and the mod-97 check |
us-ssn | US Social Security numbers written as 123-45-6789 or 123 45 6789 |
uk-national-insurance | UK National Insurance numbers |
de-tax-id | German tax identification numbers |
fr-insee | French social security numbers |
es-dni | Spanish DNI numbers |
es-nie | Spanish NIE numbers |
A number counts only as a whole value in the form it is written: never inside a longer number, a decimal or a word. With any action a rule has, this holds a message for review, refuses it, copies it, or requires TLS for it. For example, to hold outgoing mail carrying five or more card numbers:
{
"name": "Card numbers leaving",
"all": true,
"conditions": [
"outbound",
{"containsSensitive": {"detectors": ["card-number"], "atLeast": 5}}
],
"actions": [{"holdForReview": {"reason": "card numbers"}}]
}
When a rule holds a message for review because of sensitive numbers, the reason on Held for review says what was found: how many of each kind, and up to three of them masked except for their last four characters, such as found 4 payment card numbers (•••• •••• •••• 5556, …). The approver can recognise what they are releasing without seeing a whole number.
Attachments are read the way search reads them: Word, Excel, PowerPoint, OpenDocument, CSV and plain text files, within strict limits. Numbers found in them count toward the same total as the message text.
An attachment that can’t be read hides whatever is in it: a password-protected document, a damaged file, or one too big to read. {"attachmentUnreadable": true} holds for such a message, so a policy can hold it for review too:
{
"name": "Locked files leaving",
"all": true,
"conditions": ["outbound", {"attachmentUnreadable": true}],
"actions": [{"holdForReview": {"reason": "an attachment nobody can check"}}]
}
Pictures and programs have no words to read and don’t count as unreadable. A PDF’s words are read like a document’s, and a password-protected PDF counts as unreadable.
In the console’s rule form, choose The text or attachments hold sensitive numbers, then which kind and how many, or An attachment can’t be read.
Your organisation’s own data can be caught the same way, counted in the subject, the text and the attachments:
{"containsWords": {"words": ["project falcon", "codename"], "atLeast": 2}}counts whole words and phrases in any case, sofalcondoesn’t matchfalconry, and a phrase matches however it is spaced. Up to 200 words.{"containsPattern": {"pattern": "PRJ-[0-9]{4}", "atLeast": 3}}counts matches of a regular expression, in any case. A pattern that would match empty text, and so everywhere, is refused when the rules are saved.
In the rule form these are The text or attachments say words and The text or attachments match a pattern.
Actions
| Action | What it does |
|---|---|
{"score": 3.0} | Adds to the score; the thresholds decide at the end. |
{"reject": {"reason": "…"}} | Refuses the message while the sender is connected. |
{"quarantine": {"reason": "…"}} | Accepts it into quarantine. The reason shown there names the rule. |
{"tag": {"prefix": "[EXTERNAL]"}} | Delivers it to Junk, marked as junk, with the prefix at the front of its subject. With "prefix": null the prefix is [SPAM]. |
{"addHeader": {"name": "…", "value": "…"}} | Adds a header of your own. The name must be printable ASCII without a colon or a space, and not one the server writes itself: Authentication-Results, ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal, DKIM-Signature, Received, Return-Path, or anything beginning X-Versealx-. |
{"copyTo": "compliance@acme.example"} | Sends a copy to this address as well. See Copies. |
{"disclaimer": {"text": "…", "at": "bottom"}} | Adds this text to the message, "bottom" (the default) or "top". See Disclaimers. |
{"route": "smtp.partner.example:587"} | Sends outgoing mail through this server instead of the recipients’ own. See Routing through a partner. |
{"holdForReview": {"reason": "…"}} | Keeps the message for an administrator to look at before it goes. Outgoing mail waits in Held for review, incoming mail in quarantine. |
{"requireTls": "verified"} | Sends the message only over a protected connection. See Requiring TLS. |
"deliver" | Runs no further rule. The stages after the rules — ClamAV and milters — still run, so a rule cannot let mail past the scanner. A deliver in the server’s rules also skips the tenant’s. |
The first reject, quarantine or tag a rule reaches decides the message and ends the pipeline. The mail flow actions (copyTo, disclaimer, route, holdForReview, requireTls) decide nothing: the rules after them still run, and the stages after the rules, the scanner among them, still see the message. A rule that holds a message for review cannot let it past the scanner.
A rule that only reports (below) notes what these actions would have done and does none of them.
Copies
A copy goes to the address as a message of its own, from the empty sender, so a copy that cannot be delivered sends nothing back. Each copy carries a signed mark, and a message that arrives with a valid mark is never copied again, so two rules or two servers cannot copy a message back and forth. A mark that is forged or altered does not stop a message from being copied. A message is copied to at most ten addresses, each once. A copy is made only for mail being delivered now: nothing already delivered is copied when a rule is saved.
Disclaimers
The text is added to the message’s plain text and to its HTML. The message stays well-formed: the result is checked, and if it is not intact the disclaimer is left out.
Outgoing mail gets the disclaimer before the server signs it (DKIM), so receivers verify the signature and DMARC passes. A disclaimer is never added to a message somebody else has already signed: mail signed with S/MIME or OpenPGP, or any message that already carries a DKIM signature. Adding text would break the signature. In practice this means most incoming mail, and mail from mail apps that sign.
A mailing list’s copies carry the disclaimer as its post was taken, and are signed in the list’s name as they leave.
Routing through a partner
route sends outgoing mail through the named server instead of looking up each recipient’s own. The name must be a host name, not an address, and the port 25, 465 or 587. The connection is always TLS, with the host’s certificate checked against its name, and it is never made to a private or internal address. A route applies only to recipients outside the organisation, and yields to a relay the server’s operator has set: an operator’s relay always comes first.
Requiring TLS
requireTls sets how strongly the connection to the next server must be protected:
| Value | What it means | The risk |
|---|---|---|
"verified" | Sent only to a server whose certificate DANE or MTA-STS vouches for, and that promises to keep the message under TLS onward (REQUIRETLS, RFC 8689). | A receiver without these does not get the message at all, and the sender is told it could not be delivered. |
"encrypted" | Sent only over TLS, never in the clear. The certificate is checked only where DANE or MTA-STS says it can be. | Somebody between the two servers who answers in the receiver’s name is not noticed. |
When two rules ask for different values, the stronger one holds.
A tag’s prefix is written into the copy that is filed, once: a subject that already begins with it, because a filter upstream tagged it, is left alone. A subject or prefix that is not plain ASCII is written as encoded words, and a message with no subject gets the prefix as its subject. Nothing else in the message changes, and a Sieve script sees the subject as it will be filed. The prefix is not written into a notification back to the sender, and conversations thread as before, because threading sets a bracketed tag aside.
Trying a rule first
Set "reportOnly": true and the rule does nothing but record what it would have done: the tag RULE_<id>_REPORT appears in the message’s filter evidence and in the message trace. When it catches what you meant and nothing else, remove reportOnly.
Saving rules
Tenant administrators can see and change rules in the console under Rules: switch a rule between enforced, report only and off, or edit the whole list as JSON. Auditors see the list and every earlier version.
The whole list is saved at once. Read it, change it, and write it back with the version you read:
sudo -u versealx versealx-server admin get tenants/1/rules
sudo -u versealx versealx-server admin put tenants/1/rules version=3 \
'rules=[{"id":"phish","name":"Credential phishing","conditions":[{"subject":{"contains":"confirm your password"}}],"actions":[{"quarantine":{"reason":"asks for a password"}}]}]'
If somebody saved in between, the server answers 409 and you read again. A list that cannot run is refused with the rule named: a pattern that does not compile, two rules with the same id, or a rule with no conditions or no actions. Running nodes use saved rules within five seconds, with no restart. Every version is kept: GET tenants/1/rules/history shows what a rule said when a message was filtered, and each change is in the audit log.
Tenant administrators change their tenant’s rules; auditors can read them. Only the server operator changes tenant 0’s.
Previewing a change
Before you save a change, you can see what the last 30 days of mail would have done under it. The server replays the proposed list against its message traces, beside the list as it is, and counts what would have gone differently: for example, 412 messages to Junk instead of the inbox, 3 of them from people you write to.
Traces keep each message’s envelope and how it was decided, but not its content. So a rule about content (its subject, body or attachments) is judged by whether a rule like it decided the message at the time, and those figures are marked as estimates. A preview reads at most 20,000 messages, newest first, and says when it stopped. Nothing is saved.
On the console, use the Preview impact card on the Filter rules page. From the command line:
sudo -u versealx versealx-server admin rules preview rules.json
sudo -u versealx versealx-server admin rules set rules.json --what-if
Through the API, send POST /tenants/{tenant}/rules/preview with rules in the same form as saving them. A list that saving would refuse is refused the same way.
Allowed and blocked senders
There are three lists of senders somebody always wants, and senders somebody never wants: the organisation’s, each domain’s, and each person’s. An entry names an address (anna@partner.example) or a domain (partner.example, which also covers every name under it, such as eu.partner.example), and says whether to allow or block it. Only the organisation’s list may also name a network (198.51.100.0/24 or a single address).
| List | Kept by | Where it applies |
|---|---|---|
| The organisation’s | Tenant administrators; auditors read it | At the door, while the sender is connected, for everybody |
| A domain’s | Tenant administrators, and the domain’s own administrators; auditors read it | Where each copy is filed, for the people whose own address is in that domain |
| A person’s | The person, from their mail app | Where their copy is filed, for them alone |
Which list wins
The organisation’s settings win over a domain’s, and a domain’s over a person’s:
- Every block is looked for before any allow, from the top: the organisation’s list, then the domain’s, then the person’s. An allow lower down never undoes a block above it.
- A person may block any sender for themselves, including one the organisation or their domain allows.
- A person’s or a domain’s allow only spares the message the spam score, and only where no list above blocks the sender.
What a block and an allow do
- The organisation’s block refuses the sender while they are connected, whoever they claim to be.
- The organisation’s allow skips the spam score and greylisting. The malware checks, ClamAV, milters and the sender’s own DMARC policy still apply.
- A domain’s or a person’s block files that recipient’s copy in Junk. The sender is told nothing, the copy is not forwarded, and no out-of-office reply goes back.
- A domain’s or a person’s allow files the copy as though the spam score had not judged it. It never undoes a rule, a milter, the malware scan or an edge’s decision. Mail the spam score refuses at the door, and greylisting, happen before a domain’s or a person’s list is asked; only the organisation’s allow reaches those.
Every From: field of a message is read. A block holds when any author, or the envelope sender, matches it. An allowed address or domain counts only for a message with one author, whose domain passed DMARC: anybody can write any address in From:, so an entry for a name only lets through mail that proved it may use that name. An allowed network needs no proof, because a connection cannot pretend to come from somewhere else.
Mail that a mailing service sends on somebody’s behalf usually has the service’s own bounce address as its envelope sender, and the organisation’s domain in From:. Allow the domain in From:: that is the name DMARC proves.
The organisation’s list
Tenant administrators change it in the console under Senders, where auditors can read it:
vsx admin senders list
vsx admin senders allow partner.example --note 'Our supplier'
vsx admin senders block spam.example
vsx admin senders remove partner.example
When an entry allows a sender and another blocks it, blocking wins. The list is saved whole, with the version you read, like the rules. Over the API it is GET and PUT on /api/v1/tenants/{tenant}/senders.
Previewing a change to the list
Before you add or remove an entry, you can see what the last 30 days of mail would have done with it, as you can for a change to the rules: how many messages would have gone somewhere else, and where. Blocks, and allows on a network, are judged exactly. An allow on an address or a domain holds only for a From: that DMARC proved, which the trace does not keep, so those figures are marked as estimates. Nothing is saved.
On the console, use the Preview impact card on the Senders page. From the command line:
vsx admin senders preview block bulk.example
vsx admin senders allow partner.example --what-if
Through the API, send POST /api/v1/tenants/{tenant}/senders/preview with entries, the whole list as you would save it.
A domain’s list
A domain’s list is on the domain’s page in the console, and on the command line:
vsx admin domain senders sales.example.com
vsx admin senders domain sales.example.com block pushy-vendor.example --note 'Asked to stop'
vsx admin senders domain sales.example.com allow partner.example
vsx admin senders domain sales.example.com remove partner.example
It applies to the people whose own address is in the domain. Over the API it is GET and PUT on /api/v1/tenants/{tenant}/domains/{name}/senders, saved whole with the version you read.
A person’s own list
A person keeps their own list from their mail app. Nixt Mail offers it, and any JMAP app can use the JMAP sender lists extension. The same list is at the person’s own account doors, with their password and second step: POST /account/senders reads it, POST /account/senders/add with who (an address or a domain) blocks a sender, or allows one with choice set to allow, and POST /account/senders/remove takes one off. A person’s list holds up to 1,000 addresses and domains. It may not name the organisation’s own domains or addresses: mail between colleagues is for the administrators to control.
Administrators see how many senders a person allows and blocks, on the person’s page and with:
vsx admin people senders ada@example.com
Which senders a person lists is theirs. Only an auditor may look, and must say why:
vsx admin people look-at-senders ada@example.com --reason 'Complaint 4411: missing invoices'
The audit log records who looked and the reason they gave, never the entries. Over the API the counts are GET /api/v1/tenants/{tenant}/accounts/{id}/senders; an auditor adds ?reason= and says why, to see the entries.
The classifier
The classifier learns what your people consider spam from one thing: where they move mail.
- Moving a message into Junk teaches it that the message is spam.
- Moving a message out of Junk to the inbox or any other folder except Trash teaches it that the message is not spam.
- Deleting junk teaches nothing, and neither does the server filing a message into Junk itself.
It works the same in every mail app, because it watches the moves rather than any one app. Moving a message back undoes what the first move taught. Each tenant’s classifier learns from that tenant’s people only, and one person teaches it at most 200 messages a day (the classifier.lessons_per_day runtime setting), so nobody can outweigh everyone else.
It starts scoring once a tenant has taught it 50 spam and 50 non-spam messages. From then on each incoming message gets one of these tags:
| Tag | Score | When |
|---|---|---|
CLASSIFIER_SPAM | +5.0 | 99% or more likely spam |
CLASSIFIER_PROBABLY_SPAM | +2.5 | 90% or more |
CLASSIFIER_UNSURE | 0 | in between |
CLASSIFIER_PROBABLY_HAM | −1.0 | 10% or less |
CLASSIFIER_HAM | −3.0 | 1% or less |
On its own the classifier can file a message into Junk but never quarantines or refuses one; that takes other evidence as well.
The classifier stores counts of hashed words, not the words themselves. Tenant administrators see how much it has learnt, switch it off or on, and make it forget in the console under Classifier. Or, to see how much a tenant has taught it:
sudo -u versealx versealx-server admin get tenants/1/classifier
{"mode": "on", "learntSpam": 212, "learntNotSpam": 480, "minimum": 50, "scoring": true}
If it has been taught badly, make it forget everything and start again:
sudo -u versealx versealx-server admin delete tenants/1/classifier
To turn it off for a tenant, or for the whole server, set the classifier.mode runtime setting to off. It then neither learns nor scores, and keeps what it has already learnt.
Near-duplicates of reported mail
The same spam or phishing is sent many times with a name, a number or a link changed. When a message is reported, the filter remembers the shape of its text — a hash, never the words — and scores the next message close to it:
| Reported as | Tag | Score |
|---|---|---|
| Spam (moved to Junk) | FUZZY_SPAM | +6, enough to file it in Junk |
| Phishing | FUZZY_PHISHING | +8, enough to quarantine it |
- A shape counts only once two different people have reported it, or the organisation has: a phishing verdict found later, or a message enough people reported. One person moving the company newsletter to Junk changes nothing for anybody else.
- Moving a message back out of Junk takes that person’s report back.
- Only what a message says itself is compared: quoted lines, a forwarded message and a signature are left out, so a colleague forwarding a lure to ask “is this real?” isn’t taken for the lure.
- Very short messages aren’t compared at all.
- A shape is forgotten thirty days after it was last reported.
- Attachments have shapes too. Each of a message’s first eight attachments of a kilobyte or more is remembered with it: by its words when it has words to read (a PDF, a document, a web page), otherwise by its bytes. An attachment close to a reported one scores as the text would, tagged
FUZZY_ATTACHMENT_SPAMorFUZZY_ATTACHMENT_PHISHING, and the evidence names the attachment.
Sender history
The filter remembers what your people have done with mail from each sender, and scores the next message from that sender by it.
A sender here is only what DMARC proved: the From: domain DMARC passed for, and the author’s address when it is under that domain. A message that fails DMARC is not scored by any history and adds nothing to one, so a forger can neither borrow a bank’s good name nor spoil it.
What counts:
| What people do | Counts as |
|---|---|
| Move a message into Junk | Unwanted |
| Report it as phishing | Unwanted, three times over |
| Move it out of Junk, or release it from quarantine | Wanted, twice over |
| Receive it in their mailbox | Wanted, lightly: twenty messages count as one person’s say |
- One person, one say. Each person has one say about a sender, whatever they last did. Moving fifty of a sender’s messages into Junk counts once, and moving them back changes that person’s say rather than adding to it. It takes many people to move a sender’s history.
- A message counts once. A message reaching a hundred of your people is one delivery, and at most 50 of a sender’s messages are counted a day.
- Nothing is said of a new sender. A history scores only once three people have said something, or twenty messages have arrived.
- An address, then its domain. The author’s own history is used when it has one; otherwise the domain’s, at half the points.
- Old history fades. A sender unheard from for 30 days has its counts halved the next time it writes, and one unheard from for 180 days starts again from nothing. A person’s say lapses after 90 days.
- An allow wins. A sender on the organisation’s allowed list is not scored by its history.
- Your own mail teaches it too. A reply from a sender to a message your people sent that very address counts as wanted, as much as a release, and as a say of its own. Your mail to an address refused because it does not exist counts against that address and its domain, as half a move into Junk. Up to 50 of each are counted.
The score is tagged SENDER_HISTORY_BAD (toward spam) or SENDER_HISTORY_GOOD (away from it), and its detail names whose history it was and what was counted, so Checking filter decisions shows why.
It adds or takes away at most 3 points, less than the classifier’s reach, so a history leans on the verdict rather than making it. Change the most it may move a score with the sender_history.points runtime setting, from 0 to 10; 0 turns it off.
Seeing and forgetting a sender’s history
The Sender history card on Settings lists every sender with a history, domains first: how many people called its mail junk, phishing or wanted, how many of its messages were delivered, when it was last heard from, and the score its next message would get. An auditor can read it; an administrator can Forget a sender, which clears its counts and every person’s say so it starts again from nothing. Each forget is in the audit log.
From the command line:
versealx-server admin sender-history list
versealx-server admin sender-history forget domain cheap-offers.example
Through the API, GET /tenants/{tenant}/sender-history lists a page at a time, and DELETE /tenants/{tenant}/sender-history/{kind}/{name} forgets one sender, where kind is domain or address.
Impersonation
Authentication proves which domain a message came from. It says nothing about the name shown beside the address, or about a domain registered to look like yours. Ada Lovelace <ada.ceo@freemail.example> and exarnple.com pass every check there is, and they are how most business email compromise begins. The impersonation stage looks for both in incoming mail.
Domains that imitate yours. The sender’s domain, and the domain replies go to, are compared with your organisation’s domains and your TLS agreement partners’ domains:
| How | Example, imitating example.com | Adds |
|---|---|---|
A look-alike: letters a glance mixes up (rn for m, vv for w, cl for d, 0 for o, 1 for l) | exarnple.com, examp1e.com | 8 |
| A typo: one changed, missing, doubled or swapped letter (two for long names) | exmaple.com | 6 |
| The same name under another suffix | example.co, example.co.uk | 5 |
| Your name inside a longer one | example-login.com, secureexample.support | 5 |
An imitation of a partner’s domain adds one point less. Your own subdomains are yours, and short names aren’t compared by typo, so ibm.com isn’t taken for a look-alike of ibn.com.
Links that imitate yours. Every link in the message’s text is compared the same way. A link to login.exarnple.com adds what a sender at exarnple.com would, tagged URL_IMITATION, and the trace names the link and what it imitates. Links to your own domains and their subdomains are never counted. Every link is read, and up to 500 different hosts of a message are compared, in the order it links them; past that, URL_IMITATION_NOT_CHECKED is noted.
A link that says one place and goes to another. When a link’s visible text is itself an address, such as www.example.com, and the link goes to a different domain, URL_TEXT_MISMATCH adds 2.5, once per message. Words like “click here” are never read as an address. Newsletters do this for click tracking, so on its own it only adds to the other signs, never files a message.
Your people’s names on other addresses. The name a message is shown from is compared with the names of your organisation’s people. Case, accents, punctuation and word order are ignored, and an initial stands for the word it begins, so Lovelace, Ada, A. Lovelace and a near-miss like Ada Lovelaec all read as Ada Lovelace. A message from such a name at an address that isn’t one of that person’s adds 6, or 8 when the address also carries their name (ada.lovelace.ceo@…). One-word names such as Support are never compared. Mail from your own domains isn’t checked by name.
Names and domains that mix alphabets. Letters from different alphabets can look identical: Аdam with a Cyrillic А, or аpple.com with a Cyrillic а. Each word of the name a message is shown from, and each label of its sender’s and reply domains, is read with Unicode’s rules for mixed scripts (UTS #39). Real writing mixes alphabets only in known ways, such as Japanese mixing kanji and kana, so a word that mixes them any other way adds 6, tagged IMPERSONATION_MIXED_SCRIPT, and the trace names the alphabets. A name written wholly in one alphabet, such as Иван Petrov, is never counted.
Context. A message that fails DMARC, asks for replies at another domain, or asks for a payment, a change of bank details, gift cards or a password adds one more for each. A subject that presses for haste (Urgent, ASAP) adds half a point. When one of the recipients has written to that exact address before, the score is cut to a quarter: that’s a correspondent, not a stranger.
With the default thresholds, an imitation of your own domain is quarantined, and a colleague’s name from outside is filed in Junk or, with other signs, quarantined. The reason is in the message’s trace, and a flagged message carries a header a mail app can warn from:
X-Versealx-Impersonation: domain=example.com; how=typo
X-Versealx-Impersonation: name=Ada Lovelace
X-Versealx-Impersonation: mixed-script=name; level=minimally-restrictive
How firmly. Each organisation chooses what an impersonation finding does with the filtering.impersonation_level runtime setting, or Impersonation protection among the console’s mail security settings:
| Setting | What a finding does |
|---|---|
strict (the default) | The message is quarantined. |
standard | The message is filed in Junk. |
off | The message is delivered with the header only. |
A sender one of the recipients has written to before is never quarantined or filed in Junk for this alone.
A sender on your organisation’s allowed list isn’t checked.
Mail from outside. Every incoming message that isn’t from your own domains, with DMARC proving it, carries a header saying so. When none of its recipients has written to the sender before, the header also says it’s a first contact:
X-Versealx-External: yes; first-contact
A mail app can show an “outside your organisation” notice from it. The server also marks the subject of mail from outside with [External] , once: a subject that already has the tag anywhere, in any case, is left as it is. The tag is written only into the copy filed in the recipient’s mailbox, never into mail forwarded on. Turn it off with the filtering.external_subject_tag runtime setting set to off, or Mark outside mail in the console; it is on by default.
Look-alike domains found before their first message
Once a day the server looks for domains registered to imitate the organisation’s own: every look-alike its impersonation checks would flag, under the commonest endings, is looked up through the server’s own resolver. One that exists is checked further: when it was registered, whether it receives mail, and the certificate its website presents. A look-alike that is newly registered, receives mail or has a certificate is added to the names the filter watches, so mail from it is quarantined on arrival, before anybody has to report it. Nothing is sent to the look-alike beyond those lookups.
Your TLS partners’ domains and the partner domains you protect are watched the same way.
Each newly found look-alike raises the lookalike-domain alert for the organisation’s administrators, once, saying what it imitates, when it was registered, and whether it receives mail or has a website and whose certificate. The Impersonation card lists every look-alike found; Ours marks a domain your organisation owns, which is never raised or watched again. From the command line or the API:
versealx-server admin impersonation lookalikes
versealx-server admin impersonation ours examp1e.com
GET /tenants/{tenant}/lookalikes and POST /tenants/{tenant}/lookalikes/{domain}/ours. Marking a domain Ours is in the audit log.
Certificates as they are issued. An operator can also have the server read the public certificate transparency logs directly, where every certificate is published within minutes of being issued, so a look-alike is found as soon as it gets a certificate, before its website or mail is even working. Name the logs under [lookalike_watch]; each certificate’s names are compared with every organisation’s domains, and a look-alike found is kept, raised and quarantined as above. It is off unless logs are named, because following a log is a lot of traffic.
A server that cannot reach the internet sets asks = false under [lookalike_watch] in its configuration.
Protecting more names and domains
Everyone in your directory, your own domains and your TLS partners are protected already. Add what they don’t cover:
- Names, such as a title (
Chief Executive), a team (Payroll Team) or a director who has no mailbox here. Give the addresses the name is really sent from. Shown from any other address, it’s treated as a colleague’s name from outside. With no addresses, only your own domains may show it. A protected name has two words or more. - Domains of partners and suppliers you deal with, such as the firm that sends your invoices. Look-alikes of them are caught as a partner’s are.
On the console, the Impersonation card on Settings lists both, with Protect and Remove, and Test a sender shows what the filter would find in a sender before any mail arrives. From the command line:
versealx-server admin impersonation show
versealx-server admin impersonation protect-name 'Chief Executive' --address ceo@example.com
versealx-server admin impersonation protect-domain supplier.example
versealx-server admin impersonation test 'Chief Executive <chief@freemail.example>'
versealx-server admin impersonation test billing@example.com --reply-to pay@supp1ier.example
versealx-server admin impersonation unprotect-name 'Chief Executive'
versealx-server admin impersonation unprotect-domain supplier.example
Through the API, GET and PUT /tenants/{tenant}/impersonation read and replace the whole list against its version, and POST /tenants/{tenant}/impersonation/test answers for one sender. Every change is in the audit log, and it’s in force within five minutes.
S/MIME signatures
When a message arrives signed with S/MIME, the smime stage checks the signature: the signer’s certificate carried in the message, the digest of what was signed, and the signature itself. It handles RSA (PKCS #1 v1.5 and PSS, 2048 to 8192 bits) and ECDSA on P-256 and P-384, with SHA-256, SHA-384 and SHA-512. It records one of these in the evidence and in the message trace, and adds nothing to the score:
| Evidence | Meaning |
|---|---|
SMIME_SIGNED_VALID | The signature holds. The trace names the addresses in the signer’s certificate, its issuer, and whether one of them is the message’s From address. |
SMIME_SIGNED_INVALID | The signature does not hold, for example because the message was changed after it was signed. The trace says why. |
SMIME_SIGNED_UNVERIFIED | The signature could not be checked: an algorithm it does not handle (SHA-1 among them), a certificate the message does not carry, or a signature not in DER. |
When the signature holds, the signer’s certificate is also followed, through the certificates the message carries, to a root trusted for email: by default the 91 roots Mozilla trusts for email, which ship with the server. Each certificate on the way must be signed by the next, in date, and above the signer’s a certificate authority, and the signer’s must be one for email. The result is recorded beside the signature, unscored: SMIME_CHAIN_TRUSTED with the root it reached, or SMIME_CHAIN_UNTRUSTED with why. A certificate a sender made for themselves is SMIME_CHAIN_UNTRUSTED. To change the roots, see [smime]. The stage runs before the rules, so a rule can act on what it found, for example {"evidence": "SMIME_SIGNED_INVALID"}.
Cancelled certificates
A certificate whose key was stolen is cancelled (revoked) by the authority that issued it, which publishes a list of the certificates it has cancelled. The server keeps those lists: every certificate on a signer’s chain names where its issuer publishes its list, the server fetches it, checks the issuer’s signature on it, and refreshes it every six hours, so checking a message never waits on an issuer. A signer whose certificate is on its issuer’s list is SMIME_CHAIN_UNTRUSTED, and the trace says when it was cancelled and why, for example revoked on 2026-10-02, reason: key compromise. A certificate whose list could not be had, or that names no list, is SMIME_CHAIN_UNTRUSTED too, with the reason: a signature is shown as trusted only when the server could check it was not cancelled.
Nothing about your mail goes to an issuer: the lists are public files the server downloads whole. The same check covers the certificates behind BIMI logos.
The setting smime.revocation is one of:
| Value | What it does |
|---|---|
lists | The default and what the Strict profile asks for: the issuers’ lists, as above. |
lists-and-live | The lists, and where a certificate’s list can’t be had, its issuer is asked about that one certificate (OCSP). The answer is kept and refreshed in the background like a list, and used only when the issuer, or a responder the issuer authorised, signed it and it is in date. The issuer learns that a server at your address asked about that certificate, and when. |
off | Nothing is checked; a chain is trusted as it was before. |
OpenPGP signatures
When a message arrives signed with OpenPGP — a multipart/signed message with an application/pgp-signature part, or a plain-text message signed inline (-----BEGIN PGP SIGNED MESSAGE-----), quoted or not — the pgp stage checks the signature against the sender’s key. It takes the key from one of two places, and never fetches one:
- The key the organisation’s Web Key Directory publishes for the From address, when the sender is one of your people.
- Otherwise, the key in the message’s
Autocrypt:header, when that header is for the From address.
It handles RSA, ECDSA and Ed25519 with SHA-256, SHA-384 and SHA-512, and records one of these in the evidence and in the message trace, adding nothing to the score:
| Evidence | Meaning |
|---|---|
PGP_SIGNED_VALID | The signature holds. The trace gives the key’s fingerprint, where the key was found, and whether the key is for the From address. |
PGP_SIGNED_INVALID | The signature does not hold, for example because the message was changed after it was signed. The trace says why. |
PGP_SIGNED_UNVERIFIED | The signature could not be checked: no key was found, the key cannot be used, or the algorithm is not handled (SHA-1 among them). |
A key from an Autocrypt: header is the sender’s own word, so PGP_SIGNED_VALID with such a key says the message is unchanged since that key signed it, not who holds the key. The stage runs before the rules, so a rule can act on what it found.
QR codes
A link printed as a QR code passes every check made of a message’s text, and the person who scans it does so on a phone. The qr stage reads the QR codes in a message’s pictures — attachments and inline images in PNG, JPEG, GIF and WebP — and in the pictures inside Word, Excel, PowerPoint, OpenDocument and PDF attachments, and weighs where each one leads. A code a PDF draws as squares rather than holds as a picture is read too. The message trace names the picture a code was found in, and for a document, the document and the picture inside it, or for a PDF, the page (drawn on page 2 for a drawn code).
| Tag | Score | When |
|---|---|---|
QR_URL | 0 | Every link found in a code, so the message trace shows what the picture held. |
QR_URL_ADDRESS | 2.0 | The link is to an IP address rather than a name. |
QR_URL_PUNYCODE | 1.5 | The link’s name is written in punycode (xn--). |
QR_URL_ELSEWHERE | 1.0 | The link is to a different organisation from the one the message is from. |
QR_SIGN_IN_LURE | 8.0 | The link is to a different organisation, and the message asks to sign in, verify, or keep an account. |
A code that is not a link, such as a Wi-Fi card or a ticket, scores nothing. A message asking to sign in through a code that leads elsewhere reaches the quarantine threshold on its own.
A code’s link is also checked the way a link in the message’s text is:
- On your URL blocklists. Its host is looked up on the domain lists you name under DNS blocklists, and a listing adds that list’s weight, tagged
QR_URIBL_and the list’s zone, for exampleQR_URIBL_DBL_SPAMHAUS_ORG. A list that vouches for the host adds its negative weight asQR_URIBL_ALLOW_and the zone. A list that refuses the query scores nothing. A host the message’s text also links to is looked up once, by thereputationstage. Up to five hosts of a message’s codes are looked up; past that,QR_URL_NOT_LOOKED_UPis noted. With no domain lists named, nothing is looked up. - For look-alikes. Its host is compared with your organisation’s domains and your partners’, as the sender’s domain is. A host that imitates one, such as
login.exarnple.comforexample.com, addsQR_URL_IMITATIONwith the score an imitating sender would get.
Up to twenty pictures of a message are read, each up to 10 MiB and 8,000 pixels a side. Larger pictures, and ones that cannot be read, are skipped. Of documents, up to ten are opened, up to ten pictures from each, and 64 MiB in all is unpacked from them. A document inside a document is not opened, and a document that is encrypted, damaged or larger is skipped. Each thing skipped is noted as QR_DOCUMENT_SKIPPED, which scores nothing, so the message trace says what was not read. The stage shares the time budget with the other scoring stages; when it runs out, QR_UNREAD is noted and the message is judged on the rest. A picture crowded with the squares that mark a code’s corners, more than a hundred of them, is not searched either, and is noted as QR_UNREAD with the picture named.
A rule can act on any of these tags, for example to quarantine every message with QR_URL_ADDRESS.
How new a link’s domain is
A phishing link usually points at a domain registered days before the message was sent; a real bank’s or supplier’s domain is years old. The domain_age stage asks the registry of each link’s domain when it was registered, using RDAP, the registries’ own lookup service, and adds to the score when the domain is new:
| Tag | Score | When |
|---|---|---|
DOMAIN_NEW | 3.5 | The youngest linked domain was registered under 7 days ago. |
DOMAIN_RECENT | 1.0 | The youngest linked domain was registered under 30 days ago. |
DOMAIN_AGE_LATE | 0 | A registry had not answered in time. Its answer is kept for the next message. |
The message trace says it in words, for example registered 3 days ago. A domain’s age never decides a message on its own; it adds to what the other checks found. Links read from QR codes are included.
The setting
links.domain_age decides which links are asked about:
| Value | Asks about |
|---|---|
every | Every link’s domain. The default, and what the Strict profile asks for. |
suspicious | Only links another check already doubted: a look-alike, a domain on a URL blocklist, a link read from a QR code, or any link in mail that failed DMARC. |
off | Nothing. |
curl -X PUT https://mail.example.com/api/v1/tenants/1/settings \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"links.domain_age": "suspicious"}'
The operator can set a floor so organisations can’t go below a value.
What registries learn
Only the domain is sent, never the link or anything about the message: a link to login.example-bank.com/reset?u=ada is asked about as example-bank.com. A domain in other scripts is asked about in the ASCII form its registry keeps (пример.рф as xn--e1afmkfd.xn--p1ai). Your organisation’s own domains are never asked about. Each registry learns that a server at your address was sent a link to that domain. Answers are kept for 30 days, so a domain is asked about about once a month, and registries are asked at a polite pace.
The server ships with IANA’s list of which registry serves which domain ending. To use a newer copy, name it in the node’s configuration; versealx-server doctor warns when the list in use is more than 90 days old:
[rdap]
bootstrap = "/etc/versealx/rdap-dns.json"
DNS blocklists
The reputation stage looks the sender up in the DNS blocklists you name, and adds each listing to the score. Nothing is looked up until you name a list.
[[filter.blocklists]]
zone = "zen.spamhaus.org"
kind = "ip"
weight = 3.0
codes = { "127.0.0.2" = 6.0, "127.0.0.10" = 1.0 }
[[filter.blocklists]]
zone = "dbl.spamhaus.org"
kind = "domain"
weight = 4.0
kind = "ip"looks up the connecting server’s address.kind = "domain"looks up the envelope sender’s domain and every host the message links to.weightis what a listing adds to the score. A negative weight makes the list an allowlist, for a list that vouches for senders.codesweighs each answer on its own, for a list whose answers mean different things. Withcodesset, only the answers it names count.- A listing adds the tag
DNSBL_<ZONE>orURIBL_<ZONE>, with the zone in capitals and dots and hyphens as underscores:DNSBL_ZEN_SPAMHAUS_ORG. An allowlist addsDNSBL_ALLOW_<ZONE>. A list that refuses the query or does not answer addsDNSBL_ERROR_<ZONE>, which scores nothing. - Up to 50 linked hosts are looked up, one for each domain first. A message that links more domains than that has 50 of them chosen at random, and adds
URL_NOT_LOOKED_UP, which scores 1.0: links added to push another out of the lookups cost the message something. - Answers are kept for 30 minutes, so a busy sender costs one lookup.
Many lists refuse queries sent through public resolvers, and some charge for heavy use. Read each list’s terms, and use a resolver of your own. See Configuration for every key.
Greylisting
Greylisting asks a sender never seen before to try again later, because real mail servers retry and most spam software does not. Here it only delays mail that already looks bad: a message is greylisted only when its score so far reaches score, so ordinary mail from somebody new is never delayed.
[filter.greylist]
score = 3.0
delay_seconds = 300
window_hours = 24
- The sender is known by its network (a /24 for IPv4, a /64 for IPv6) and its envelope sender’s domain, and every node of a cluster shares what it has seen.
- On first contact the sender is told
451 4.7.1 greylisted: please try again later, and the evidence recordsGREYLISTED. - A retry sooner than
delay_secondsafter first contact is refused again. A sender that comes back after that is known from then on. - A sender seen once, that did not come back within
window_hours, starts again as a stranger. - Bounces, allowed senders and outgoing mail are never greylisted.
ClamAV
[filter.clamav]
socket = "/run/clamav/clamd.ctl" # or "127.0.0.1:3310"
timeout_seconds = 10
on_unavailable = "accept"
- The whole message is sent to clamd for scanning.
- A message clamd reports as infected is refused with
550 5.7.1 message refused: malware detected (<signature>), and the evidence recordsCLAMAV_INFECTED. - A clean message records
CLAMAV_CLEAN. - A message larger than 25 MiB is not sent and records
CLAMAV_SKIPPED. - When clamd cannot be reached, the evidence records
CLAMAV_UNAVAILABLE, andon_unavailabledecides:acceptlets the message through,tempfailanswers451 4.7.1 malware scanner unavailable: <reason>so the sender retries later.
Choose tempfail if an unscanned message must never be accepted; choose accept if a scanner outage must never delay mail.
Milters
A milter is an external filter that speaks the Sendmail milter protocol. Rspamd is the most common.
[[filter.milters]]
name = "rspamd"
socket = "127.0.0.1:11332"
timeout_seconds = 10
on_unavailable = "accept"
The server passes each milter the connection details, the sender, the recipients, the headers and the body, and acts on its answer:
| Milter says | Result | Evidence tag |
|---|---|---|
| Accept or continue | The pipeline carries on. | MILTER_<NAME>_ACCEPT |
| Reject | Refused with 550 5.7.1 and the milter’s own reply text, or refused by <name>. | MILTER_<NAME>_REJECT |
| Temporary failure | Deferred with 451 4.7.1 and the milter’s text, or try later, says <name>. | MILTER_<NAME>_TEMPFAIL |
| Discard | Kept in Quarantine rather than dropped. | MILTER_<NAME>_DISCARD |
| Quarantine | Kept in Quarantine. | MILTER_<NAME>_QUARANTINE |
| Add a header | The header is added to the delivered message. | — |
| Cannot be reached | on_unavailable decides, as for ClamAV. | MILTER_<NAME>_UNAVAILABLE |
<NAME> is the milter’s name in capitals, with hyphens, dots and spaces turned into underscores.
Outgoing mail
Messages from signed-in people go through the same pipeline, with one difference: a decision to refuse, quarantine or defer holds the message instead, and the sending app sees:
550 5.7.1 Message held: <reason>
Most often this is the attachment policy or a milter. The person can remove the attachment and send again.
Junk and Quarantine
- Junk. A tagged message is delivered to the account’s Junk mailbox and given the
$junkkeyword. People review it in their mail app. - Quarantine. A quarantined message is stored in a Quarantine mailbox that mail apps are not shown. It is kept, with the reason it was quarantined.
What people see
Once a day, each person with newly quarantined mail finds a message in their inbox titled, for example, 2 messages were kept out of your inbox. It lists who each message says it is from, its subject and why it was held, and links to https://<your server>/quarantine.
On that page they sign in with their email address and password — or, if their organisation signs them in, type their address and choose Sign in with your organisation — and see everything in their quarantine. Release to inbox delivers a message as new mail; Delete removes it. They stay signed in there for fifteen minutes. The page works in any browser and needs no mail app.
A message only appears in one digest. Turn digests off for a tenant, or for the whole server, with the quarantine.digest runtime setting:
sudo -u versealx versealx-server admin patch tenants/1/settings quarantine.digest=off
People can still open the page themselves.
Releasing quarantined mail as an administrator
Administrators see a tenant’s quarantine in the console under Quarantine, or with the API:
sudo -u versealx versealx-server admin get tenants/1/quarantine
The list comes a page at a time, the most recently held first, from every account. An answer with more to come ends with a cursor; pass it back as cursor= for the next page. Narrow it to one sender’s address or domain, or to what one stage of the filter held:
sudo -u versealx versealx-server admin get tenants/1/quarantine from=phish.example
sudo -u versealx versealx-server admin get tenants/1/quarantine stage=thresholds
The stages are rules, senders, auth, reputation, malware, clamav, milter and classify, as the pipeline names them, thresholds for the spam score, edge for mail the edge in front of this server held, and reported for a copy the server took back after delivery as reported phishing.
Each entry shows the account, who sent it, its size, when it was quarantined, why — the stage that decided, as stage, and its reason — and the queue ID to look it up in the message trace. When DMARC passed, it also shows the domain the sender proved, as authenticatedDomain. The subject, the From: address and the message itself are not shown: they belong to the person it was sent to.
- Release a message that was caught by mistake. It arrives in the person’s inbox as new mail.
- Release and allow a message from a sender you always want: it is released and its proved domain is added to the allowed senders. The console offers this only when the sender proved a domain.
- Discard one you are sure of. It is deleted.
sudo -u versealx versealx-server admin post tenants/1/quarantine/<account>/<message>/release
sudo -u versealx versealx-server admin delete tenants/1/quarantine/<account>/<message>
Tenant and domain administrators can release and discard; a domain administrator sees only their own domains. Helpdesk staff can release but not discard. Auditors can look. Every release and discard is recorded in the audit log.
How long quarantined mail is kept
Anything nobody releases is deleted after 30 days. Change that per tenant, or for the whole server, with the quarantine.keep_days runtime setting:
sudo -u versealx versealx-server admin patch tenants/1/settings quarantine.keep_days=14
Checking filter decisions
- The message trace records a
filteredstep for every message accepted on port 25, naming the verdict, the stage that decided and the score. See Message trace. vsx_messages_refused_total{role="mx",reason="filter"}counts messages the filter refused.- The log line
filteredcarries the same verdict, stage and score, never the message.
Something unclear or out of date on this page? Tell us.