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

FormatTOML.
Written byversealx-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.
ReadOnce, when a command starts. Change the file and restart the service for run to see the change.
Checked byversealx-server check, which refuses with the reason, and the packaged service, which runs check before every start.
Explained byversealx-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:

KeyTypical use
nodenode = "${HOSTNAME}", so every container or pod started from one file has its own name.
[store] urlurl = "${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

KeyTypeDefaultMeaning
nodestringRequiredThis 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}.
hostnamehost nameRequiredThe 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.
roleslist of role namesEvery roleThe 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"]
RoleRuns
mxThe SMTP listener on port 25.
submissionThe submission listeners on 587 and 465.
relayThe outbound queue, the daily TLS and DMARC reports, and message-trace expiry.
filterAccepted for completeness. The filter pipeline runs inside mx and submission; this role starts nothing of its own.
storeIMAP (143, 993), POP3 (110, 995), ManageSieve (4190), JMAP and the OAuth endpoints (443).
deliverFinal delivery into mailboxes, Sieve, vacation replies and forwarding.
davCalDAV and CardDAV (443).
adminThe local admin socket, and the admin API over HTTPS when [listeners.admin] is set.
serveAutoconfig, 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"
KeyTypeDefaultMeaning
kind"sqlite"RequiredOne SQLite file on this machine.
pathpathRequiredThe database file. It is created on first start.

PostgreSQL

[store]
kind = "postgres"
url = "${VERSEALX_STORE_URL}"
KeyTypeDefaultMeaning
kind"postgres"RequiredPostgreSQL 15 or later, shared by every node of an installation.
urlstringRequiredA 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"
KeyTypeDefaultMeaning
kind"filesystem"RequiredFiles in a directory.
pathpathRequiredThe directory. It is created on first start.
coldpathNoneA second, cheaper directory for old mail, on another disk. See Old mail on a cheaper disk.
cold_after_daysinteger90How old a message must be to move to cold. At least 7.
previouspath or s3://bucketNoneWhere 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_monthtableNoneWhat 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"
KeyTypeDefaultMeaning
kind"s3"RequiredAn S3-compatible bucket.
bucketstringRequiredThe bucket name.
endpointURLThe AWS S3 endpoint for regionThe service’s endpoint, for anything other than AWS. With an endpoint of your own, path-style addressing is used.
regionstringus-east-1The bucket’s region.
previouspath or s3://bucketNoneWhere 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.

KeyTypeDefaultMeaning
sitestringNoneThe site such a place rests in when --site does not say. The organisation’s residency is checked against it.
directorieslist of pathsNoneThe directories such a place may be in. A directory outside them is refused.
path_stylebooleanPath-style for any endpoint that is not AWSPath-style bucket addressing.
access_key_envstringAWS_ACCESS_KEY_IDThe environment variable holding the buckets’ access key.
secret_key_envstringAWS_SECRET_ACCESS_KEYThe 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"
KeyTypeDefaultMeaning
kind"file" or "kms"RequiredWhere the key is.
pathpathRequired for "file"A file holding 32 bytes, or 64 hexadecimal characters.
previous_filepathNoneDuring 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.

KeyTypeDefaultMeaning
key_idstringRequiredThe KMS key’s ARN, which names its region.
endpointURLhttps://kms.<region>.amazonaws.comWhere the KMS is, for anything other than AWS.
trustpathNoneA PEM certificate to trust for the endpoint, beside the public roots.
previous_key_idstringNoneDuring 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"
KeyTypeDefaultMeaning
kind"files"RequiredCertificates you supply.
certificatepathRequiredThe PEM certificate chain, leaf first.
keypathRequiredThe PEM private key.

ACME

[tls]
kind = "acme"
names = ["mail.example.com", "mta-sts.example.com"]
contact = "postmaster@example.com"
KeyTypeDefaultMeaning
kind"acme"RequiredCertificates obtained and renewed automatically. Needs the serve role.
nameslist of host namesRequired, not emptyThe names the certificate covers.
contactstringRequiredThe contact address for the certificate authority account.
directoryURLhttps://acme-v02.api.letsencrypt.org/directoryAnother certificate authority’s ACME directory.
trustpathNoneA 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"
KeyTypeDefaultMeaning
bindsocket addressRequired in the tableThe address and port to listen on, such as 0.0.0.0:25 or [2001:db8::10]:25. Each listener binds one address.
proxy_protocolbooleanfalseExpect a PROXY protocol header (version 1 or 2) from a load balancer before the conversation, so the server sees the client’s real address.
ListenerRoleDefault addressProtocol
mxmx0.0.0.0:25SMTP with STARTTLS
submissionsubmission0.0.0.0:587Submission with STARTTLS
submissionssubmission0.0.0.0:465Submission over TLS
imapstore0.0.0.0:143IMAP with STARTTLS
imapsstore0.0.0.0:993IMAP over TLS
pop3store0.0.0.0:110POP3 with STLS
pop3sstore0.0.0.0:995POP3 over TLS
managesievestore0.0.0.0:4190ManageSieve with STARTTLS
jmapstore0.0.0.0:443JMAP and OAuth over HTTPS
davdav0.0.0.0:443CalDAV and CardDAV over HTTPS
adminadminNot listeningThe admin API over HTTPS. Listens only when this table is present.
dradminNot listeningThe 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.
serveserve0.0.0.0:443Autoconfig, Autodiscover and MTA-STS over HTTPS
acmeserve0.0.0.0:80ACME 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"]
KeyTypeDefaultMeaning
max_connectionswhole number1,000 for SMTP, 2,000 for IMAP, 500 for POP3 and ManageSieveConnections at once, from everybody.
max_per_ipwhole number20 on port 25, 50 for submission, 30 for IMAP, 20 for POP3 and ManageSieveConnections at once from one address.
max_per_networkwhole number200Connections at once from one network.
rate_per_iprateNoneHow fast one address may open connections, as connections per second, minute, hour or day: "60/minute".
rate_per_networkrateNoneHow fast one network may open connections.
exemptlist of stringsEmptyAddresses 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:

ProtocolReply
SMTP421 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
ManageSieveBYE "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
KeyTypeDefaultMeaning
message_sizebytes26214400 (25 MiB), which is also the most allowedThe largest message accepted over SMTP and submission, advertised as SIZE.
recipientswhole number100, which is also the most allowedRecipients one message may name, advertised as LIMITS RCPTMAX.
in_flight_mbMiB256How 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
KeyTypeDefaultMeaning
concurrencywhole number8Queue 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.
defaulttableThe built-in ceilingsA ceiling for every destination without its own entry. Naming it replaces the built-in table, including the entries for the large mailbox providers.
destinationstable of tablesNoneCeilings by destination.
relaytableNoneSend everything that is not for a local domain through this smart host.
transporttable of tablesNoneSend mail for particular destinations through their own relay.
trustpathNoneA 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_addressesbooleanfalseAllow 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>"].

KeyTypeDefaultMeaning
connectionswhole numberSee belowConnections open to the destination at once. At least 1.
messages_per_connectionwhole numberSee belowMessages sent down one connection before it is closed and another opened. At least 1.
messages_per_minutewhole numberNo ceilingMessages a minute.
recipients_per_minutewhole numberNo ceilingRecipients 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:

DestinationConnectionsMessages per connectionMessages a minute
Any other destination420No ceiling
gmail.com, googlemail.com, and everything under google.com81001,200
outlook.com, hotmail.com, live.com, msn.com330300
yahoo.com, ymail.com, aol.com550600
icloud.com, me.com, mac.com440400

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}"
KeyTypeDefaultMeaning
hosthost nameRequiredThe relay’s host name, which its certificate must carry.
portportRequired587 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.
userstringNoneThe identity to authenticate as.
passwordstringNoneThe password. Write it as ${NAME} to read it from the environment.
certificatepathNoneA 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.
keypathNoneThe certificate’s PEM private key. Required with certificate.
sitestringNoneThe 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:

  1. A local domain: delivered into mailboxes on this server.
  2. A matching [outbound.transport] entry.
  3. [outbound.relay], when there is one.
  4. 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.

KeyTypeDefaultMeaning
domaindomainRequiredThe domain.
recipientslist of stringsRequiredWhom to take mail for: whole addresses at the domain, or @ and the domain for every address at it.
mailboxes.namehost nameNoneThe server that holds the domain’s mailboxes and sends its mail out through this node, by the name its certificate carries.
mailboxes.authoritypathRequired with mailboxesA 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.

KeyTypeDefaultMeaning
nameslist of host namesRequiredThe name each edge server’s certificate carries, which is also the name it writes into Authentication-Results.
authoritypathRequiredA 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"]
KeyTypeDefaultMeaning
namestringRequiredWhat the log and the message trace call it. Each device’s is its own.
fromlist of stringsRequiredWhere 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.
senderaddressRequiredThe 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.
recipientslist of stringsRequiredWhom it may send to: whole addresses, or @domain for every address at one.
messages_per_hourwhole number100Messages 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"]
KeyTypeDefaultMeaning
mode"enforce", "testing" or "none"Unset: no policy is publishedtesting 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.
mxlist of host namesEmptyOther 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
KeyTypeDefaultMeaning
socketpath/run/versealx-server/admin.sockThe local socket versealx-server admin talks to. It is created with mode 0600; whoever can open it acts as the operator.
trace_dayswhole number30Days 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
KeyTypeDefaultMeaning
breach_checkbooleantrueWhether 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_apiURLhttps://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_mswhole number3000Milliseconds 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_trustpathNoneA 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
KeyTypeDefaultMeaning
budget_secondswhole number15Seconds 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]

KeyTypeDefaultMeaning
socketstringRequiredclamd’s socket: a path beginning with /, or host:port.
timeout_secondswhole number10Seconds 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.

KeyTypeDefaultMeaning
zonestringRequiredThe zone lookups are made under, such as zen.spamhaus.org.
kind"ip" or "domain"Requiredip looks up the connecting address; domain the envelope sender’s domain and every host the message links to.
weightnumberRequiredWhat a listing adds to the score. Negative for a list that vouches for senders. Not 0.
codestableNoneWhat 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_secondswhole number3Seconds one lookup may take.
cache_minuteswhole number30How long an answer is kept.

A zone may be named once for each kind.

[filter.greylist]

See Greylisting. Unset, nobody is greylisted.

KeyTypeDefaultMeaning
scorenumberRequiredOnly a message scoring at least this, from a sender not seen before, is greylisted.
delay_secondswhole number300A retry sooner than this after first contact is refused again. At least 1.
window_hourswhole number24A 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.

KeyTypeDefaultMeaning
trusted_sealerslist of domainsEmptyThe 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.

KeyTypeDefaultMeaning
namestringRequiredA name for the milter, used in the message trace and in scoring tags. Must be unique.
socketstringRequiredA path beginning with /, or host:port.
timeout_secondswhole number10Seconds 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.

KeyTypeDefaultMeaning
shippedbooleantrueWhether the roots that ship with the server are trusted.
rootspathNoneMore roots to trust: a PEM file, or a directory whose .pem files are read in name order.
distrustlist of stringsNoneRoots 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.

KeyTypeDefaultMeaning
shippedbooleantrueWhether the roots that ship with the server are trusted.
rootspathNoneMore roots to trust: a PEM file, or a directory whose .pem files are read in name order.
distrustlist of stringsNoneRoots 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.

KeyTypeDefaultMeaning
bootstrappathNoneA 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
KeyTypeDefaultMeaning
asksbooleantruefalse for a server that cannot reach the internet: nothing is looked up and no log is read.
logslist of URLsNoneCertificate transparency logs to read directly, by their RFC 6962 address, from the lists the browsers publish. None is read unless named.
entries_per_hournumber20000The 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"
KeyTypeDefaultMeaning
toabsolute pathnoneWhere backups go. With none, no daily backups are taken.
key_filepathnoneThe 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_dailyinteger, at least 114How many days’ backups are kept.
keep_weeklyinteger8How many weeks, the newest of each.
keep_monthlyinteger12How many months, the newest of each.
sitestringnoneThe 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.

KeyTypeDefaultMeaning
rolestringnoneprimary or standby. Recorded in the store the first time; after a promotion the store’s own record decides. Unset, the site takes no part.
primarystringnoneOn a standby: the address of the primary’s admin API, which dr follow copies from and dr promote tells.
keepinteger1,000,000On a primary: the most changes kept that the standby hasn’t confirmed. A standby further behind copies the primary whole again.
hold_spoolpathnoneWhere 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_bindaddressnoneWhere dr hold takes mail: the standby’s port 25.
link_dirpathnoneThe 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.

KeyTypeDefaultMeaning
automaticbooleanfalseTake over, or stop taking changes, on the witness’s word. A risk refused until accepted: [risks] accept = ["dr-automatic-failover"].
witnessstringrequiredThis site’s address at the witness, https://host:port.
witness_link_dirpathrequiredThe folder link files wrote for this site’s link with the witness.
lease_secondsinteger30How 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"
KeyTypeDefaultMeaning
namestringrequiredThe name the domain’s MX record points at.
addressIP addressrequiredThis site’s address, which the name should give once this site leads.
changesstringnoneroute53 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_idstringnoneThe Route 53 hosted zone’s id, for changes = "route53".
ttlinteger60The record’s TTL in seconds, when the server changes it.
endpointstringRoute 53host:port of a Route 53-compatible service, for anything but AWS’s own.
trustpathnoneA PEM root to trust for endpoint.

[cluster]

This node’s cluster. See Nodes of a cluster.

KeyTypeDefaultMeaning
namestringthe host nameThe 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_secondsinteger30How long a drained node lets open connections carry on before closing them. 0 leaves them open; the node only turns new connections away.

Where the server keeps each link’s files when it renews them; see Linking the two sides by code.

KeyTypeDefaultMeaning
dirpathnoneA 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.

KeyTypeDefaultMeaning
allow_privatebooleanfalseAllow 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.

KeyTypeDefaultMeaning
allow_privatebooleanfalseAllow 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_portbooleanfalseAllow push addresses on a port other than 443. Push services answer on 443; leave it off unless yours does not.
trustpathnoneA 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
KeyTypeDefaultMeaning
reserve_percentinteger, 1 to 10095How 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"
KeyTypeDefaultMeaning
scrubbooleantrueWhether this node takes part. The nodes of a cluster share the scrub, so one node’s false leaves it to the others.
scrub_daysinteger, 1 to 36530How many days one pass through everything takes. It never reads slower than 256 KiB a second.
standby_blobsstringnoneThe 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_endpointstringnoneThe standby bucket’s endpoint, for storage that is not AWS.
standby_regionstringnoneThe 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"
KeyTypeDefaultMeaning
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"]
KeyTypeDefaultMeaning
trust_anchorslist of pathsEmptyFiles 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"
KeyTypeDefaultMeaning
metrics_bindsocket addressUnset: metrics are kept but not servedWhere 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.

MessageWhat 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 roleRemove the table, or add the role.
limits.<key> = <value> would raise the ceiling of <max>; ceilings can only be loweredUse 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 nameFix or add names under [tls] names.
store.url `<url>` is not a postgres:// URLStart the URL with postgres:// or postgresql://, or use a ${NAME} reference.
[filter]: clamav.socket `<value>` is neither a Unix socket path nor host:portGive a path beginning with /, or host:port.
[filter]: milter `<name>`'s socket `<value>` is neither a Unix socket path nor host:portThe same, for a milter.
[filter]: a milter needs a `name`, for the trace to say who decidedGive every milter a name.
[filter]: two milters are named `<name>`; the trace could not tell them apartMake 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 activationRun 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.