Configuration
A complete reference for versealx-server.toml — every section and key, with its type, default and meaning, and the messages check prints.
A node’s configuration file holds what the node needs before it can open its store: its name, its roles, where its data lives, its certificate, its listeners, and a few ceilings that belong to the machine. Everything else — domains, accounts, per-tenant policies, and ceilings you change during an incident — lives in the store and is changed through the API. See Runtime settings for those.
The file
| Format | TOML. |
| Written by | versealx-server init, at /etc/versealx-server/versealx-server.toml unless you give --config. It is created readable only by its owner. |
| Found by | --config <file> after the subcommand; otherwise the VERSEALX_CONFIG environment variable; otherwise versealx-server.toml in the current directory. --config - reads the file from standard input. |
| Read | Once, when a command starts. Change the file and restart the service for run to see the change. |
| Checked by | versealx-server check, which refuses with the reason, and the packaged service, which runs check before every start. |
| Explained by | versealx-server explain, which prints what runs where, what each role listens on, what leaves the machine, and the ceilings the file sets. |
A key the server does not know is refused rather than ignored in the top level and in the [limits], [outbound], [mta_sts], [admin], [filter], [dns] and [telemetry] tables, so a typing mistake cannot silently turn a setting off.
Values from the environment
Three values may name an environment variable as ${NAME}, which is replaced with the variable’s value when the node starts:
| Key | Typical use |
|---|---|
node | node = "${HOSTNAME}", so every container or pod started from one file has its own name. |
[store] url | url = "${VERSEALX_STORE_URL}", so the database password is not in the file. |
password in [outbound.relay] and [outbound.transport.*] | password = "${VERSEALX_RELAY_PASSWORD}", so the relay’s password is not in the file. |
A variable that is not set becomes an empty string. An empty node stops the node from starting.
Minimal example
This is the file init writes for --hostname mail.example.com --domain example.com:
node = "mail.example.com"
hostname = "mail.example.com"
[store]
kind = "sqlite"
path = "/var/lib/versealx-server/store.db"
[blobs]
kind = "filesystem"
path = "/var/lib/versealx-server/blobs"
[keys]
kind = "file"
path = "/var/lib/versealx-server/kek"
[tls]
kind = "files"
certificate = "/var/lib/versealx-server/tls/certificate.pem"
key = "/var/lib/versealx-server/tls/key.pem"
[admin]
socket = "/var/lib/versealx-server/admin.sock"
[mta_sts]
mode = "testing"
With --relay, it also writes an [outbound.relay] table.
Top-level keys
| Key | Type | Default | Meaning |
|---|---|---|---|
node | string | Required | This node’s own name, unique among the nodes that share a store. It labels the metrics and the leases a node takes. May be ${NAME}. |
hostname | host name | Required | The name the server greets other servers with, writes into Received headers and uses in the addresses it tells mail apps to connect to. Must be a valid DNS name: labels of letters, digits and hyphens, none starting or ending with a hyphen, at most 63 characters each and 253 in all. |
roles | list of role names | Every role | The roles this node runs. Leave it out, or give an empty list, to run all nine. |
Roles
roles = ["mx", "submission", "relay", "filter", "store", "deliver", "dav", "admin", "serve"]
| Role | Runs |
|---|---|
mx | The SMTP listener on port 25. |
submission | The submission listeners on 587 and 465. |
relay | The outbound queue, the daily TLS and DMARC reports, and message-trace expiry. |
filter | Accepted for completeness. The filter pipeline runs inside mx and submission; this role starts nothing of its own. |
store | IMAP (143, 993), POP3 (110, 995), ManageSieve (4190), JMAP and the OAuth endpoints (443). |
deliver | Final delivery into mailboxes, Sieve, vacation replies and forwarding. |
dav | CalDAV and CardDAV (443). |
admin | The local admin socket, and the admin API over HTTPS when [listeners.admin] is set. |
serve | Autoconfig, Autodiscover and MTA-STS policies (443), the ACME challenge listener (80), and certificate renewal. |
Every accepted message goes on a queue that the relay role works through: it delivers to other servers itself and hands mail for local recipients to the deliver role. So at least one node sharing the store must run relay and at least one must run deliver, or accepted mail waits. A node with kind = "acme" needs serve.
[store]
Where the metadata lives: the directory, mailboxes, message index, queues, settings, audit log and keys.
SQLite
[store]
kind = "sqlite"
path = "/var/lib/versealx-server/store.db"
| Key | Type | Default | Meaning |
|---|---|---|---|
kind | "sqlite" | Required | One SQLite file on this machine. |
path | path | Required | The database file. It is created on first start. |
PostgreSQL
[store]
kind = "postgres"
url = "${VERSEALX_STORE_URL}"
| Key | Type | Default | Meaning |
|---|---|---|---|
kind | "postgres" | Required | PostgreSQL 15 or later, shared by every node of an installation. |
url | string | Required | A connection URL beginning postgres:// or postgresql://, or a ${NAME} reference to one. explain prints it with the password replaced by ***. |
[blobs]
Where message bodies live. Every message is encrypted with its tenant’s key before it is written.
A directory
[blobs]
kind = "filesystem"
path = "/var/lib/versealx-server/blobs"
| Key | Type | Default | Meaning |
|---|---|---|---|
kind | "filesystem" | Required | Files in a directory. |
path | path | Required | The directory. It is created on first start. |
cold | path | None | A second, cheaper directory for old mail, on another disk. See Old mail on a cheaper disk. |
cold_after_days | integer | 90 | How old a message must be to move to cold. At least 7. |
previous | path or s3://bucket | None | Where the mail was before a move, read when a message is not in the new place yet. See Moving the store and the mail. |
price_per_gb_month | table | None | What a GB a month costs on each disk, as { main = 0.10, cold = 0.02 }, for the monthly estimate in storage status. Nothing is fetched. |
An S3-compatible bucket
[blobs]
kind = "s3"
bucket = "mail-blobs"
endpoint = "https://s3.storage.example.net"
region = "eu-1"
| Key | Type | Default | Meaning |
|---|---|---|---|
kind | "s3" | Required | An S3-compatible bucket. |
bucket | string | Required | The bucket name. |
endpoint | URL | The AWS S3 endpoint for region | The service’s endpoint, for anything other than AWS. With an endpoint of your own, path-style addressing is used. |
region | string | us-east-1 | The bucket’s region. |
previous | path or s3://bucket | None | Where the mail was before a move, read when a message is not in the new place yet. See Moving the store and the mail. |
The access key and secret come from the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables. If either is missing the node does not start, with blobs: AWS_ACCESS_KEY_ID is not set or blobs: AWS_SECRET_ACCESS_KEY is not set.
[organisation_blobs]
How a node reaches the places organisations keep their mail in apart from its own, set with tenant storage. Which organisation is where is kept in the store, so every node needs the same settings here.
| Key | Type | Default | Meaning |
|---|---|---|---|
site | string | None | The site such a place rests in when --site does not say. The organisation’s residency is checked against it. |
directories | list of paths | None | The directories such a place may be in. A directory outside them is refused. |
path_style | boolean | Path-style for any endpoint that is not AWS | Path-style bucket addressing. |
access_key_env | string | AWS_ACCESS_KEY_ID | The environment variable holding the buckets’ access key. |
secret_key_env | string | AWS_SECRET_ACCESS_KEY | The environment variable holding their secret. |
A node that cannot reach an organisation’s place, for example because its credentials are not set, does not start and says which organisation’s place it could not open, so no mail is written anywhere else.
[keys]
Where the key-encryption key comes from. Each tenant’s data key is stored wrapped by this key, and each message is encrypted with its tenant’s data key.
[keys]
kind = "file"
path = "/var/lib/versealx-server/kek"
| Key | Type | Default | Meaning |
|---|---|---|---|
kind | "file" or "kms" | Required | Where the key is. |
path | path | Required for "file" | A file holding 32 bytes, or 64 hexadecimal characters. |
previous_file | path | None | During a rotation without stopping: the key in use before, beside the new one in path. Removed once doctor says nothing uses it. |
If the file does not exist when the node starts, the node generates a new key, writes it with mode 0600, and logs generated a new key-encryption key; back it up. The node refuses to use a file that its group or other users can read, with keys: bad key material: <path> is readable by others (mode 640); chmod 600 it.
A key in AWS KMS
[keys]
kind = "kms"
key_id = "arn:aws:kms:eu-west-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"
With a KMS, the key-encryption key is never on disk as itself. The store keeps the KMS’s ciphertext of it, and each node asks the KMS to unwrap it when it starts, so a disk, a backup or a copy of the store holds nothing that opens anything without the KMS.
| Key | Type | Default | Meaning |
|---|---|---|---|
key_id | string | Required | The KMS key’s ARN, which names its region. |
endpoint | URL | https://kms.<region>.amazonaws.com | Where the KMS is, for anything other than AWS. |
trust | path | None | A PEM certificate to trust for the endpoint, beside the public roots. |
previous_key_id | string | None | During a rotation without stopping: the KMS key in use before, beside the new one in key_id. Removed once doctor says nothing uses it. |
The first node to start on an empty store has the KMS make the key, and every node after it unwraps that one. A node the KMS refuses — access denied, the key disabled, or a ciphertext wrapped by another key — does not start, and says why. Every call carries the encryption context versealx-server: key-encryption-key, so each unwrap is recorded under that name in your account’s KMS audit trail.
Credentials come from the environment (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, with AWS_SESSION_TOKEN for temporary ones), then a container’s task role, then the instance’s role. The key’s policy must allow the node kms:GenerateDataKey and kms:Decrypt, and kms:Encrypt for keys wrap.
A node that has run on a key file moves to a KMS with keys wrap: set [keys] as above, stop the nodes, and run versealx-server keys wrap --file <the old key file>. It checks the file’s key against the store first and changes nothing if it is not this store’s.
[tls]
The certificate every listener uses. TLS certificates explains both kinds in full.
Certificate files
[tls]
kind = "files"
certificate = "/var/lib/versealx-server/tls/certificate.pem"
key = "/var/lib/versealx-server/tls/key.pem"
| Key | Type | Default | Meaning |
|---|---|---|---|
kind | "files" | Required | Certificates you supply. |
certificate | path | Required | The PEM certificate chain, leaf first. |
key | path | Required | The PEM private key. |
ACME
[tls]
kind = "acme"
names = ["mail.example.com", "mta-sts.example.com"]
contact = "postmaster@example.com"
| Key | Type | Default | Meaning |
|---|---|---|---|
kind | "acme" | Required | Certificates obtained and renewed automatically. Needs the serve role. |
names | list of host names | Required, not empty | The names the certificate covers. |
contact | string | Required | The contact address for the certificate authority account. |
directory | URL | https://acme-v02.api.letsencrypt.org/directory | Another certificate authority’s ACME directory. |
trust | path | None | A PEM certificate to trust, besides the public roots, when talking to the certificate authority. |
[listeners]
Every listener has a default address. Add a [listeners.<name>] table only to change one.
[listeners.mx]
bind = "0.0.0.0:25"
proxy_protocol = true
[listeners.imaps]
bind = "[2001:db8::10]:993"
| Key | Type | Default | Meaning |
|---|---|---|---|
bind | socket address | Required in the table | The address and port to listen on, such as 0.0.0.0:25 or [2001:db8::10]:25. Each listener binds one address. |
proxy_protocol | boolean | false | Expect a PROXY protocol header (version 1 or 2) from a load balancer before the conversation, so the server sees the client’s real address. |
| Listener | Role | Default address | Protocol |
|---|---|---|---|
mx | mx | 0.0.0.0:25 | SMTP with STARTTLS |
submission | submission | 0.0.0.0:587 | Submission with STARTTLS |
submissions | submission | 0.0.0.0:465 | Submission over TLS |
imap | store | 0.0.0.0:143 | IMAP with STARTTLS |
imaps | store | 0.0.0.0:993 | IMAP over TLS |
pop3 | store | 0.0.0.0:110 | POP3 with STLS |
pop3s | store | 0.0.0.0:995 | POP3 over TLS |
managesieve | store | 0.0.0.0:4190 | ManageSieve with STARTTLS |
jmap | store | 0.0.0.0:443 | JMAP and OAuth over HTTPS |
dav | dav | 0.0.0.0:443 | CalDAV and CardDAV over HTTPS |
admin | admin | Not listening | The admin API over HTTPS. Listens only when this table is present. |
dr | admin | Not listening | The other site’s disaster-recovery standby, by the link between them, and nothing else. Listens only when this table and [dr] link_dir are present, on an address of its own. |
serve | serve | 0.0.0.0:443 | Autoconfig, Autodiscover and MTA-STS over HTTPS |
acme | serve | 0.0.0.0:80 | ACME HTTP-01 challenges, in plain HTTP. Listens when [tls] kind = "acme", or when this table is present. |
Listeners whose role the node does not run are not started. jmap, dav, admin and serve on the same address share one HTTPS listener; give them different addresses and each gets its own.
The ports mail apps are told about in autoconfig and Autodiscover follow the listeners, so moving imaps to another port moves it there too. The SRV records versealx-server dns prints always use the default ports; if you move a listener, change the SRV record you publish.
check refuses a table for a listener that does not exist, and a table for a listener whose role the node does not run.
Connection ceilings
The mail listeners — mx, submission, submissions, imap, imaps, pop3, pop3s and managesieve — limit how many connections one address and one network may hold at once, and can limit how fast they open them. A network is an IPv4 /24 or an IPv6 /48; an IPv4 client that reaches an IPv6 socket counts as its IPv4 address. Behind a load balancer with proxy_protocol, the client’s own address is the one counted.
[listeners.mx]
bind = "0.0.0.0:25"
max_per_network = 100
rate_per_ip = "30/minute"
rate_per_network = "300/minute"
exempt = ["192.0.2.0/24", "2001:db8:10::/48"]
| Key | Type | Default | Meaning |
|---|---|---|---|
max_connections | whole number | 1,000 for SMTP, 2,000 for IMAP, 500 for POP3 and ManageSieve | Connections at once, from everybody. |
max_per_ip | whole number | 20 on port 25, 50 for submission, 30 for IMAP, 20 for POP3 and ManageSieve | Connections at once from one address. |
max_per_network | whole number | 200 | Connections at once from one network. |
rate_per_ip | rate | None | How fast one address may open connections, as connections per second, minute, hour or day: "60/minute". |
rate_per_network | rate | None | How fast one network may open connections. |
exempt | list of strings | Empty | Addresses and networks none of these ceilings apply to, such as your own ranges or a partner’s relay. max_connections still applies to them. |
A rate lets a client open as many connections as it allows at once after a quiet spell, and then one more each time its share of the period has passed: at "60/minute", one a second. A connection over any ceiling is told why before anything is read from it, in the protocol’s own words, and closed. For example:
| Protocol | Reply |
|---|---|
| SMTP | 421 4.7.0 Too many connections from your network |
| IMAP | * BYE Too many connections from your address; try again later |
| POP3 | -ERR too many connections from your network |
| ManageSieve | BYE "Too many connections from your address" |
The words name the address or the network, and a rate adds ; try again later. vsx_sessions_closed_total counts the refusals by listener and reason: per-address, per-network, address-rate or network-rate. versealx-server explain prints the ceilings each listener runs with.
Rates are off unless you set them. On port 25 a rate too low for a large sender’s burst asks its mail to come back later, so set them from what your logs show.
check refuses a ceiling of 0, a rate or an exempt entry it cannot read, and ceilings on jmap, dav, serve, admin or acme, which share their socket and keep none.
[limits]
Ceilings on one message, and on everything arriving at once. message_size and recipients can only be lowered: the built-in numbers are what the server promises in its SMTP greeting.
[limits]
message_size = 10485760
recipients = 50
| Key | Type | Default | Meaning |
|---|---|---|---|
message_size | bytes | 26214400 (25 MiB), which is also the most allowed | The largest message accepted over SMTP and submission, advertised as SIZE. |
recipients | whole number | 100, which is also the most allowed | Recipients one message may name, advertised as LIMITS RCPTMAX. |
in_flight_mb | MiB | 256 | How much the node holds in memory, all connections together, for mail being received and uploads over 64 KiB. Past it, a message is refused with 421 4.3.2 and an upload with 503 and Retry-After, and the sender tries again. Raise it on a machine with more memory. It may not be less than one message of message_size. |
The runtime settings limits.message_size and limits.recipients override these without a restart, and removing them brings the file’s numbers back. A connection keeps the numbers it was told when it connected.
[outbound]
How this node delivers mail: how many deliveries run at once, how hard each destination is pushed, which relay to use, and which certificates to trust.
[outbound]
concurrency = 16
[outbound.destinations."gmail.com"]
connections = 16
messages_per_minute = 3000
[outbound.destinations.".partner.example"]
connections = 1
messages_per_minute = 30
| Key | Type | Default | Meaning |
|---|---|---|---|
concurrency | whole number | 8 | Queue entries one delivery pass works on at once, across every destination. This is the node’s own footprint — tasks, sockets, memory. What any one destination sees is bounded by its ceiling. |
default | table | The built-in ceilings | A ceiling for every destination without its own entry. Naming it replaces the built-in table, including the entries for the large mailbox providers. |
destinations | table of tables | None | Ceilings by destination. |
relay | table | None | Send everything that is not for a local domain through this smart host. |
transport | table of tables | None | Send mail for particular destinations through their own relay. |
trust | path | None | A PEM file of certificates to trust, besides the public roots, when a hop must be verified by name — an MTA-STS enforce policy, or a relay with TLS required — and when fetching an MTA-STS policy. DANE ignores it. |
allow_private_addresses | boolean | false | Allow delivery to, and policy fetches from, addresses that are not globally routable. Leave it off on any server reachable from the internet. explain prints a line in capitals while it is on. |
A destination key is a domain, such as "gmail.com", or a domain with a leading dot, such as ".google.com", for everything under it. The most specific key wins.
Destination ceilings
Used in [outbound.default] and [outbound.destinations."<domain>"].
| Key | Type | Default | Meaning |
|---|---|---|---|
connections | whole number | See below | Connections open to the destination at once. At least 1. |
messages_per_connection | whole number | See below | Messages sent down one connection before it is closed and another opened. At least 1. |
messages_per_minute | whole number | No ceiling | Messages a minute. |
recipients_per_minute | whole number | No ceiling | Recipients a minute, for receivers that count recipients. |
A key you leave out keeps what the built-in table, or [outbound.default], says for that destination.
Built-in ceilings:
| Destination | Connections | Messages per connection | Messages a minute |
|---|---|---|---|
| Any other destination | 4 | 20 | No ceiling |
gmail.com, googlemail.com, and everything under google.com | 8 | 100 | 1,200 |
outlook.com, hotmail.com, live.com, msn.com | 3 | 30 | 300 |
yahoo.com, ymail.com, aol.com | 5 | 50 | 600 |
icloud.com, me.com, mac.com | 4 | 40 | 400 |
These sit well inside what those providers publish, so a new server is paced rather than refused. Raise them once your server has a reputation. The runtime settings outbound.destination.<domain>.* override the file for one destination without a restart; see Runtime settings.
[outbound.relay]
[outbound.relay]
host = "smtp.relay.example.net"
port = 587
tls = "required"
user = "postmaster@example.com"
password = "${VERSEALX_RELAY_PASSWORD}"
| Key | Type | Default | Meaning |
|---|---|---|---|
host | host name | Required | The relay’s host name, which its certificate must carry. |
port | port | Required | 587 for submission with STARTTLS, 465 for submission over TLS, 25 for a relay on a network you own. |
tls | "implicit", "required" or "opportunistic" | "required" | implicit: TLS from the first byte, as on port 465. required: STARTTLS, and the certificate must verify. opportunistic: STARTTLS if offered, otherwise carry on unencrypted — only for a relay on a network you own. |
user | string | None | The identity to authenticate as. |
password | string | None | The password. Write it as ${NAME} to read it from the environment. |
certificate | path | None | A PEM certificate chain, leaf first, to present when the relay asks for one: for a relay that knows this node by its certificate. See Edges and two premises. |
key | path | None | The certificate’s PEM private key. Required with certificate. |
site | string | None | The site the relay is in, by its [sites] name. Every message leaving through the relay passes through it, so it is checked against each organisation’s residency. |
The node authenticates with AUTH PLAIN, falling back to LOGIN. It never sends the credentials over an unencrypted connection, whatever the relay offers, and never writes the password to a log.
[outbound.transport]
[outbound.transport.".corp.example"]
host = "hub.corp.example"
port = 25
tls = "opportunistic"
Takes the same keys as [outbound.relay]. The key is a domain, or a domain with a leading dot for everything under it.
The route for a message is chosen in this order:
- A local domain: delivered into mailboxes on this server.
- A matching
[outbound.transport]entry. [outbound.relay], when there is one.- The recipient domain’s own MX.
The set of local domains is refreshed every second, so a domain added through the API is delivered locally at once.
[outbound.premises]
[outbound.premises.onprem]
host = "mail.onprem.example"
port = 25
tls = "required"
certificate = "/etc/versealx/cloud.pem"
key = "/etc/versealx/cloud.key"
The other premises of a domain whose mailboxes are in two places, by the name the directory gives them: mail for somebody whose mailbox is there goes to that premises’ MX rather than being filed here. Takes the same keys as [outbound.relay]. Its site is where the mailboxes there rest, checked against each organisation’s residency. A name is letters, digits and hyphens. See One domain, mailboxes in two places.
[[relay_domains]]
Domains this node takes mail for and passes on, because their mailboxes are elsewhere: an edge in front of another server, or a backup MX. Repeat the table for each domain. See Edges and two premises.
| Key | Type | Default | Meaning |
|---|---|---|---|
domain | domain | Required | The domain. |
recipients | list of strings | Required | Whom to take mail for: whole addresses at the domain, or @ and the domain for every address at it. |
mailboxes.name | host name | None | The server that holds the domain’s mailboxes and sends its mail out through this node, by the name its certificate carries. |
mailboxes.authority | path | Required with mailboxes | A PEM file of the authority that issued that server’s certificate, trusted for it and nothing else. |
[edge]
The edge in front of this node, whose checks the mail it passes on keeps. See Trusting the edge in front of you.
| Key | Type | Default | Meaning |
|---|---|---|---|
names | list of host names | Required | The name each edge server’s certificate carries, which is also the name it writes into Authentication-Results. |
authority | path | Required | A PEM file of the authority that issued those certificates, trusted for these names and nothing else. |
[[relay_devices]]
Devices that send without signing in: printers, scanners, appliances that cannot hold a password. Repeat the table for each device. See Devices that cannot sign in.
[[relay_devices]]
name = "scanner-floor-2"
from = ["192.0.2.40", "192.0.2.48/29"]
sender = "scanner@example.com"
recipients = ["@example.com", "facilities@example.net"]
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | Required | What the log and the message trace call it. Each device’s is its own. |
from | list of strings | Required | Where it connects from: addresses, or prefixes such as 192.0.2.48/29. None wider than /8 for IPv4 or /32 for IPv6, and no address may belong to two devices. |
sender | address | Required | The one envelope sender it may use. Its domain says whose mail it is and whose DKIM keys sign it, and a From header must be at the same domain. |
recipients | list of strings | Required | Whom it may send to: whole addresses, or @domain for every address at one. |
messages_per_hour | whole number | 100 | Messages it may send in any hour. |
A device is never counted as guessing at addresses: a refusal it meets is about its own configuration.
[mta_sts]
The MTA-STS policy this node publishes for a verified domain whose tenant has not set one in the runtime settings.
[mta_sts]
mode = "testing"
mx = ["mx2.example.com"]
| Key | Type | Default | Meaning |
|---|---|---|---|
mode | "enforce", "testing" or "none" | Unset: no policy is published | testing asks senders that could not deliver securely to report it and deliver anyway. enforce asks them to refuse instead. none publishes a policy that withdraws an earlier one. |
mx | list of host names | Empty | Other MX hosts the policy allows, besides this node’s hostname, which is always listed first. A sender holding an enforce policy will not deliver to an MX the policy leaves out. |
The policy’s max_age is a week unless the runtime setting mta_sts.max_age says otherwise.
[admin]
[admin]
socket = "/var/lib/versealx-server/admin.sock"
trace_days = 30
| Key | Type | Default | Meaning |
|---|---|---|---|
socket | path | /run/versealx-server/admin.sock | The local socket versealx-server admin talks to. It is created with mode 0600; whoever can open it acts as the operator. |
trace_days | whole number | 30 | Days a message trace is kept. 0 means the default. The runtime setting trace.keep_days overrides it without a restart. |
[passwords]
Whether this node checks new passwords against the ones known to have leaked in data breaches, and whom it asks. Each organisation decides whether leaked passwords are refused, with the passwords.refuse_breached runtime setting; this table decides whether the node can ask at all. See Passwords that have leaked.
[passwords]
breach_check = true
breach_api = "https://api.pwnedpasswords.com/range/"
breach_timeout_ms = 3000
| Key | Type | Default | Meaning |
|---|---|---|---|
breach_check | boolean | true | Whether the node asks the range service. false turns the check off for every organisation on the node, whatever each chose: for a node with no way out to the internet and no copy of the service inside. |
breach_api | URL | https://api.pwnedpasswords.com/range/ | The range service, with the five characters of a hash added after it. Name your own copy of the service here for a node that should not reach out. https only. |
breach_timeout_ms | whole number | 3000 | Milliseconds one lookup may take, from 100 to 60000. It is on the path of somebody choosing a password; a lookup that runs out is a check that could not be made. |
breach_trust | path | None | A PEM certificate to trust beyond the public roots, for a copy of the service whose certificate your own authority signed. |
Only the first five characters of a password’s SHA-1 hash are ever sent, never the password. versealx-server doctor asks the service once and reports it as leaked-password check, with how long it took to answer.
[filter]
External helpers for the filter pipeline. The built-in stages — authentication results and the attachment policy — always run; this table adds a virus scanner and milters after them. See Spam and malware filtering.
[filter]
budget_seconds = 15
[filter.clamav]
socket = "/run/clamav/clamd.ctl"
on_unavailable = "accept"
[[filter.milters]]
name = "rspamd"
socket = "127.0.0.1:11332"
timeout_seconds = 10
| Key | Type | Default | Meaning |
|---|---|---|---|
budget_seconds | whole number | 15 | Seconds the scoring stages may take while the sender waits, at most 300. What does not fit is finished at delivery, before anybody can read the message; 0 leaves all the scoring to delivery. The scanner and the milters are never cut. See Time budget. |
[filter.clamav]
| Key | Type | Default | Meaning |
|---|---|---|---|
socket | string | Required | clamd’s socket: a path beginning with /, or host:port. |
timeout_seconds | whole number | 10 | Seconds one scan may take. At least 1. |
on_unavailable | "accept" or "tempfail" | "accept" | When clamd cannot be reached: accept lets the message through and records that it went unscanned; tempfail asks the sender to try again later. |
[[filter.blocklists]]
Repeat the table for each list. See DNS blocklists.
| Key | Type | Default | Meaning |
|---|---|---|---|
zone | string | Required | The zone lookups are made under, such as zen.spamhaus.org. |
kind | "ip" or "domain" | Required | ip looks up the connecting address; domain the envelope sender’s domain and every host the message links to. |
weight | number | Required | What a listing adds to the score. Negative for a list that vouches for senders. Not 0. |
codes | table | None | What each answer weighs, such as { "127.0.0.2" = 6.0 }. Set, only the answers named count. Each must be a listing answer: an address in 127.0.0.0/8, other than 127.0.0.1, 127.0.1.255 and 127.255.255.0/24, which lists use to refuse a query. |
timeout_seconds | whole number | 3 | Seconds one lookup may take. |
cache_minutes | whole number | 30 | How long an answer is kept. |
A zone may be named once for each kind.
[filter.greylist]
See Greylisting. Unset, nobody is greylisted.
| Key | Type | Default | Meaning |
|---|---|---|---|
score | number | Required | Only a message scoring at least this, from a sender not seen before, is greylisted. |
delay_seconds | whole number | 300 | A retry sooner than this after first contact is refused again. At least 1. |
window_hours | whole number | 24 | A sender seen once that does not come back within this long starts again as a stranger. |
[filter.arc]
See Mailing lists and forwarders you trust.
| Key | Type | Default | Meaning |
|---|---|---|---|
trusted_sealers | list of domains | Empty | The lists and forwarders whose ARC seal lets a message that fails DMARC through, by the domain they write as d= in their ARC-Seal. Matched exactly; a wildcard is refused. |
[[filter.milters]]
Repeat the table for each milter. They are asked in the order written, after the scanner.
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | Required | A name for the milter, used in the message trace and in scoring tags. Must be unique. |
socket | string | Required | A path beginning with /, or host:port. |
timeout_seconds | whole number | 10 | Seconds one message may take. At least 1. |
on_unavailable | "accept" or "tempfail" | "accept" | As for the scanner. |
[bimi]
Checks the BIMI logos of mail you receive. See Logos on mail you receive. Without this table, no logo is checked.
| Key | Type | Default | Meaning |
|---|---|---|---|
shipped | boolean | true | Whether the roots that ship with the server are trusted. |
roots | path | None | More roots to trust: a PEM file, or a directory whose .pem files are read in name order. |
distrust | list of strings | None | Roots withdrawn, shipped or added, by the SHA-256 of the certificate, with or without colons. |
A logo whose certificate chains to none of the trusted roots is not shown.
[smime]
The roots an S/MIME signer’s certificate is chained to (see S/MIME signatures). Without this table, the roots Mozilla trusts for email are used.
| Key | Type | Default | Meaning |
|---|---|---|---|
shipped | boolean | true | Whether the roots that ship with the server are trusted. |
roots | path | None | More roots to trust: a PEM file, or a directory whose .pem files are read in name order. |
distrust | list of strings | None | Roots withdrawn, shipped or added, by the SHA-256 of the certificate, with or without colons. |
versealx-server trust roots smime lists the roots in force, and doctor warns a year before one expires.
[rdap]
The list of which registry answers for which domain ending, used to learn how new a link’s domain is (see How new a link’s domain is). Without this table, the copy of IANA’s list that ships with the server is used.
| Key | Type | Default | Meaning |
|---|---|---|---|
bootstrap | path | None | A newer copy of IANA’s list (dns.json from data.iana.org/rdap/). |
doctor says when the file does not read, and warns when the list in use was published more than 90 days ago.
[lookalike_watch]
How the server finds look-alikes of each organisation’s domains before their first message (see Look-alike domains found before their first message).
[lookalike_watch]
logs = ["https://ct.googleapis.com/logs/us1/argon2026h2/"]
entries_per_hour = 20000
| Key | Type | Default | Meaning |
|---|---|---|---|
asks | boolean | true | false for a server that cannot reach the internet: nothing is looked up and no log is read. |
logs | list of URLs | None | Certificate transparency logs to read directly, by their RFC 6962 address, from the lists the browsers publish. None is read unless named. |
entries_per_hour | number | 20000 | The most new entries of each log read an hour. |
Each certificate in a log is about 6 KB, so the default reading is about 120 MB an hour for each log; check says what yours comes to. The newest entries are read first: a log is read from where it stands when it is first named, and one that runs more than a day ahead of the reading is caught up to its newest hour, so what is found is always recent.
[backup]
Daily backups of the running node, sealed with a key of their own. See Daily backups.
[backup]
to = "/var/backups/versealx"
key_file = "/etc/versealx-server/backup.key"
at = "02:00"
| Key | Type | Default | Meaning |
|---|---|---|---|
to | absolute path | none | Where backups go. With none, no daily backups are taken. |
key_file | path | none | The backup key, made with versealx-server backups key. Required when to is set. |
at | "HH:MM", UTC | "02:00" | When each day’s backup starts. |
keep_daily | integer, at least 1 | 14 | How many days’ backups are kept. |
keep_weekly | integer | 8 | How many weeks, the newest of each. |
keep_monthly | integer | 12 | How many months, the newest of each. |
site | string | none | The site the backups directory rests in, which doctor checks against each organisation’s residency. See Daily backups. |
check refuses a to that is not absolute, a to with no key_file, an at that is not a time of day, and a keep_daily of 0.
[dr]
This site’s part in disaster recovery; see A standby site.
| Key | Type | Default | Meaning |
|---|---|---|---|
role | string | none | primary or standby. Recorded in the store the first time; after a promotion the store’s own record decides. Unset, the site takes no part. |
primary | string | none | On a standby: the address of the primary’s admin API, which dr follow copies from and dr promote tells. |
keep | integer | 1,000,000 | On a primary: the most changes kept that the standby hasn’t confirmed. A standby further behind copies the primary whole again. |
hold_spool | path | none | Where dr hold keeps the mail it takes on a standby, and where a promoted site’s nodes find what is still held. See The standby as a second MX. |
hold_bind | address | none | Where dr hold takes mail: the standby’s port 25. |
link_dir | path | none | The folder link files wrote for the link to the other site. Set, the standby follows the primary’s dr listener by the link, and the primary serves it there. See Following by the link. |
[dr.failover]
Automatic failover by a witness. See Taking over automatically.
| Key | Type | Default | Meaning |
|---|---|---|---|
automatic | boolean | false | Take over, or stop taking changes, on the witness’s word. A risk refused until accepted: [risks] accept = ["dr-automatic-failover"]. |
witness | string | required | This site’s address at the witness, https://host:port. |
witness_link_dir | path | required | The folder link files wrote for this site’s link with the witness. |
lease_seconds | integer | 30 | How long a lease lasts, from 10 to 300. The primary renews every third of it. |
[dr.dns]
The record mail is pointed at, so that a promotion says what to change, or changes it. See How mail finds the site that leads.
[dr.dns]
name = "mx.example.com"
address = "198.51.100.20"
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | required | The name the domain’s MX record points at. |
address | IP address | required | This site’s address, which the name should give once this site leads. |
changes | string | none | route53 to have the server change the record itself when promoted. A risk refused until accepted: [risks] accept = ["dns-changed-by-server"]. Unset, a promotion says what to change by hand. |
zone_id | string | none | The Route 53 hosted zone’s id, for changes = "route53". |
ttl | integer | 60 | The record’s TTL in seconds, when the server changes it. |
endpoint | string | Route 53 | host:port of a Route 53-compatible service, for anything but AWS’s own. |
trust | path | none | A PEM root to trust for endpoint. |
[cluster]
This node’s cluster. See Nodes of a cluster.
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | the host name | The cluster’s name. The first node on an empty store names the cluster with it; a node whose name differs from its store’s refuses to start. |
drain_grace_seconds | integer | 30 | How long a drained node lets open connections carry on before closing them. 0 leaves them open; the node only turns new connections away. |
[links]
Where the server keeps each link’s files when it renews them; see Linking the two sides by code.
| Key | Type | Default | Meaning |
|---|---|---|---|
dir | path | none | A folder in which each link’s certificate, key and the other side’s authority are rewritten, one folder per link, when the certificate renews. Without it, only the server’s own copy renews, and link files writes the files. |
[migrations]
Which old servers the migrations your organisations’ administrators start may reach; see Moving everybody at once.
| Key | Type | Default | Meaning |
|---|---|---|---|
allow_private | boolean | false | Allow an old server on a private, loopback or link-local address, such as a mail server inside your own network. Leave it off on any server whose organisations’ administrators are not all its operators: they could otherwise point the server at your internal network. Migrations you run yourself with versealx-server migrate are not affected. |
[push]
Where the server may send phone pushes; see Push to phones.
| Key | Type | Default | Meaning |
|---|---|---|---|
allow_private | boolean | false | Allow push addresses on private, loopback or link-local addresses, for a push relay inside your own network. Leave it off on any server whose people are not all its operators. explain prints a line in capitals while it is on. |
any_port | boolean | false | Allow push addresses on a port other than 443. Push services answer on 443; leave it off unless yours does not. |
trust | path | none | A PEM certificate to trust beyond the public roots, for a push service whose certificate a private authority signed. |
[storage]
What the node does as its disks fill. See When a disk fills.
[storage]
reserve_percent = 95
| Key | Type | Default | Meaning |
|---|---|---|---|
reserve_percent | integer, 1 to 100 | 95 | How full a volume holding the store or the blobs may get before the node stops taking new mail. 100 never stops. |
[integrity]
The integrity scrub, which reads all stored mail back on a schedule. See Checking stored mail.
[integrity]
scrub = true
scrub_days = 30
standby_blobs = "s3://mail-standby"
standby_region = "eu-west-1"
| Key | Type | Default | Meaning |
|---|---|---|---|
scrub | boolean | true | Whether this node takes part. The nodes of a cluster share the scrub, so one node’s false leaves it to the others. |
scrub_days | integer, 1 to 365 | 30 | How many days one pass through everything takes. It never reads slower than 256 KiB a second. |
standby_blobs | string | none | The standby site’s copy of the stored mail, to repair from before the backups: a directory this node can reach, or s3://bucket, using the credentials of [blobs]. |
standby_endpoint | string | none | The standby bucket’s endpoint, for storage that is not AWS. |
standby_region | string | none | The standby bucket’s region. |
[confinement]
At start, the node takes away from itself what it does not need. On Linux, with Landlock, it can then read and write only the files and directories its configuration names, bind only its listeners’ ports, and run no other program, so a process somebody took over cannot read the rest of the machine.
[confinement]
landlock = "best-effort"
| Key | Type | Default | Meaning |
|---|---|---|---|
landlock | "best-effort", "required" or "off" | "best-effort" | best-effort: as far as the kernel allows; a kernel without Landlock starts the node with a warning. required: refuse to start unless the kernel enforces it; Linux only, and check refuses it on other systems. off: do not confine, to find out whether confinement is what stops something working. |
Landlock needs Linux 5.13 or later, with landlock among the kernel’s security modules (the lsm= kernel parameter). versealx-server doctor tries the node’s own rules on a thread of its own and reports what the kernel enforces, as confinement.
[dns]
The resolver comes from /etc/resolv.conf and always validates DNSSEC against the root trust anchors.
[dns]
trust_anchors = ["/etc/versealx-server/corp.example.dnskey"]
| Key | Type | Default | Meaning |
|---|---|---|---|
trust_anchors | list of paths | Empty | Files of DNSKEY records, in the form dig DNSKEY prints, one record per line. Each zone named becomes a trust anchor beside the root, so a private zone validates the way the public tree does. |
Put the zone’s whole DNSKEY set in the file, not only its key-signing key; with the key-signing key alone, nothing under the zone validates. Nothing else may answer for the zone: a resolver in front that answers some of its names from its own table makes those answers look forged. check refuses a file that cannot be read or holds no DNSKEY record.
[telemetry]
[telemetry]
metrics_bind = "127.0.0.1:9187"
| Key | Type | Default | Meaning |
|---|---|---|---|
metrics_bind | socket address | Unset: metrics are kept but not served | Where plain HTTP /metrics, /healthz and /readyz are served. The endpoint needs no authentication, so keep it on a private address. |
See Monitoring.
More examples
A node that sends through a relay
node = "mail-1"
hostname = "mail.example.com"
roles = ["mx", "submission", "relay", "filter", "store", "deliver", "serve", "admin"]
[store]
kind = "sqlite"
path = "/var/lib/versealx-server/store.db"
[blobs]
kind = "filesystem"
path = "/var/lib/versealx-server/blobs"
[keys]
kind = "file"
path = "/var/lib/versealx-server/kek"
[tls]
kind = "acme"
names = ["mail.example.com", "autoconfig.example.com", "mta-sts.example.com"]
contact = "postmaster@example.com"
[admin]
socket = "/var/lib/versealx-server/admin.sock"
[outbound.relay]
host = "smtp.relay.example.net"
port = 587
tls = "required"
user = "postmaster@example.com"
password = "${VERSEALX_RELAY_PASSWORD}"
[mta_sts]
mode = "testing"
For the packaged service, put VERSEALX_RELAY_PASSWORD in a systemd drop-in, for example with sudo systemctl edit versealx-server and an Environment= line, rather than in this file.
An inbound edge node behind a load balancer
node = "${HOSTNAME}"
hostname = "mx1.example.com"
roles = ["mx", "relay", "filter"]
[store]
kind = "postgres"
url = "${VERSEALX_STORE_URL}"
[blobs]
kind = "s3"
bucket = "mail-blobs"
endpoint = "https://s3.storage.example.net"
[keys]
kind = "file"
path = "/var/lib/versealx-server/kek"
[tls]
kind = "files"
certificate = "/etc/versealx-server/chain.pem"
key = "/etc/versealx-server/key.pem"
[listeners.mx]
bind = "0.0.0.0:25"
proxy_protocol = true
[telemetry]
metrics_bind = "192.0.2.5:9187"
Every node sharing a store must use the same key file.
Messages from check
check and run print versealx-server: refusing to start: followed by one of these.
| Message | What to do |
|---|---|
the configuration does not parse: <detail> | Fix the TOML syntax, the unknown key, the unknown role name or the wrong value type the detail names. |
`<name>` is not a valid host name for `hostname` | Use a fully qualified DNS name. |
unknown listener `<name>`; listeners are mx, submission, submissions, … | Use one of the listener names above. |
a listener is configured for `<name>` but this node does not run that role | Remove the table, or add the role. |
limits.<key> = <value> would raise the ceiling of <max>; ceilings can only be lowered | Use a value at or below the built-in ceiling. |
tls.names must list the host names to obtain certificates for; `<name>` is not a host name | Fix or add names under [tls] names. |
store.url `<url>` is not a postgres:// URL | Start the URL with postgres:// or postgresql://, or use a ${NAME} reference. |
[filter]: clamav.socket `<value>` is neither a Unix socket path nor host:port | Give a path beginning with /, or host:port. |
[filter]: milter `<name>`'s socket `<value>` is neither a Unix socket path nor host:port | The same, for a milter. |
[filter]: a milter needs a `name`, for the trace to say who decided | Give every milter a name. |
[filter]: two milters are named `<name>`; the trace could not tell them apart | Make milter names unique. |
[dns]: trust anchor file <path>: <reason> | Fix the path, or put DNSKEY records in the file. |
refusing to run as root; run as the `versealx` user with CAP_NET_BIND_SERVICE or socket activation | Run the command as the service user. |
Problems check cannot see, such as a missing certificate file or an unreadable key file, stop run instead. Troubleshooting lists those messages.
Something unclear or out of date on this page? Tell us.