Runtime settings
The settings Nixt Server keeps in its store — every key, its scope, type and default — and how to read, change, export and import them without a restart.
Some settings are too important to wait for a restart: a destination that has started deferring your mail, a flood of oversized messages, a tenant ready to enforce its MTA-STS policy. Those live in the server’s store rather than in the configuration file, and a running node picks up a change within seconds.
The file stays the floor. A node with nothing set in the store behaves exactly as its configuration file says; a setting in the store wins while it is there; remove it, and the file’s value comes back.
Documents and scopes
Settings are kept as one document per scope:
| Scope | Where | Who may change it |
|---|---|---|
| The whole server | Tenant 0: /api/v1/tenants/0/settings | The operator only. |
| One tenant | /api/v1/tenants/<id>/settings | The operator, and the tenant’s administrator. |
Some keys can only be set for the whole server; they are marked Server below. For the other keys, the value is looked up in this order, and the first one found is used:
- The tenant’s document.
- The whole server’s document.
- The node’s configuration file, where the key has a counterpart there.
- The built-in default.
Every document has a version. Each write increases it, keeps the previous version in the history, and writes an audit line with the document before and after.
Floors and locks
For some security controls, the operator can set a floor that every organisation must keep. An organisation’s own value is used only when it is at least as strict. The operator can also lock a control, so every organisation uses the operator’s value.
| Control | Stricter is |
|---|---|
security.second_factor | off, then optional, then required |
passwords.refuse_breached | off, then on, then strict |
passwords.min_length | longer |
recovery.self_service | off |
security.step_up_minutes | fewer minutes; 0, which turns the check off, is weakest |
approvals.* | required |
approvals.operator_access | ask |
audit.keep_days | longer |
mdn.send | any, then inside, then off |
sharing.personal | off |
security.admin_second_factor | any, then passkey |
security.user_verification | required |
forwarding.external | allowed, then approval, then off |
outbound.plaintext_retry | never |
filtering.impersonation_level | off, then standard, then strict |
filtering.external_subject_tag | on |
ceilings.complaints_per_thousand | fewer; 0, which turns it off, is weakest |
offboarding.on_deprovision | offboard |
versealx-server admin security floor security.second_factor required
versealx-server admin security floor audit.keep_days 365
versealx-server admin security floor mdn.send inside --lock
versealx-server admin security floors
versealx-server admin security unfloor mdn.send
Wherever the server reads a floored setting for an organisation, a weaker or unset value is read as the floor. When the setting’s default is stricter than the floor, the default applies. An organisation that tries to save a weaker value is refused, and the error names the floor. A value it saved before the floor was set keeps working until the organisation changes it, but it is read as the floor. Through the API, the operator uses GET and PUT /api/v1/security/floors.
Floors for domains and people
An organisation can set floors of its own below the installation’s: for one of its domains, for all its people, or for the people of one domain. A domain’s administrators can set floors for their domain’s people. Each level is read under every floor above it, so the installation’s floors and locks win over the organisation’s, and the organisation’s over a domain’s.
These floors are taken only for the controls the server reads per domain or per person: security.second_factor, for domains and people, and mta_sts.mode, for domains, which is then the mode the domain publishes. A floor on any other control is refused, since nothing would hold it.
vsx admin security levels
vsx admin security floor-domain finance.example.com security.second_factor required
vsx admin security floor-domain finance.example.com mta_sts.mode enforce
vsx admin security floor-people security.second_factor required
vsx admin security floor-domain-people example.com security.second_factor required
vsx admin security unfloor-domain finance.example.com mta_sts.mode
A change under a floor is refused, naming the floor and who set it. A person whose floor requires a second step cannot remove their last one. The posture lists every floor with whom it is for and whether it holds: for MTA-STS, what the domain’s DNS last showed, and for a second step, how many people it covers still have none. A floor that does not hold is named in the drift alert. On the console, the organisation’s floors are on the Security posture card, and each domain’s page has a Security floors card, where the domain’s administrators set and remove the floors for its people. Through the API, they are GET and PUT /api/v1/tenants/<id>/security/floors, and PUT /api/v1/tenants/<id>/security/floors/<domain> for a domain.
Security posture
An organisation’s administrators and auditors can see how its controls stand against the Strict profile, including any floors the installation sets. Use the Security posture card on Settings, run versealx-server admin security posture, or call GET /api/v1/tenants/<id>/security/posture.
Strict asks for:
- a second step for everyone
- the strict leaked-password check
- passwords of at least 14 characters
- no self-service recovery
- a recent sign-in, within 10 minutes, before anything that can’t be undone
- a year of audit
The posture also checks what is actually true, not only what the settings say:
- every listener that serves TLS, connected to on the server itself the way a client would, serves at least TLS 1.2 and a certificate with at least 14 days left. A listener that couldn’t be reached is shown as not checked, never as passing;
- each of the organisation’s domains publishes DMARC at
p=reject, as DNS last showed it; - each domain publishes and serves MTA-STS in
enforcemode; - everyone has a second step;
- every administrator has a second step;
- every node refuses to send mail in the clear (BSI TR-03108 04-M);
- each domain’s MX hosts publish DANE TLSA records, in a signed zone, that match the certificate the server serves (05-M);
- each domain publishes a TLS reporting record with a report address (10-M);
- the server’s certificate comes from an issuer the operator lists in
posture.trusted_issuers(12-R). With no list, this is not checked.
A check the server could not make, such as a zone that is not signed or a lookup that failed, is shown as not checked, never as passing. The domain checks use the server’s last DNS look at each domain, which it refreshes every few hours and whenever someone asks from the domain’s page. The people who have no second step are named, up to ten per check.
The operator can raise the listeners’ floor, for example to TLS 1.3 or 30 days, on the console’s Listeners’ TLS page under System, which also shows each listener as good, out of profile with why, or not checked, or with GET and PUT /api/v1/security/listener-tls. Each listener is checked hourly.
To be told when a control falls short, turn on the below-strict alert. For each setting short of Strict it says who set its value and when, and the settings version that did it, so you can see that change in the settings history and set it back; a setting nobody changed is marked as its default.
Exceptions
Sometimes a control can’t meet Strict for a while, for a good reason: shared terminals in a call centre that can’t take a second step until new ones arrive, say. An administrator can record an exception to that control, with the reason and the day it ends, at most a year away.
An exception is a record, not a switch. It changes no setting and no floor. While it stands:
- the posture and the security report show it beside the control, with who granted it and why;
- the
below-strictalert lists the control as excepted until its day instead of short.
When the day passes, the control counts against Strict again, and the alert says so.
On the Security posture card, use Exceptions to grant one, and End to end one early. Or run:
vsx admin security except security.second_factor 2027-03-31 'the call centre terminals, until they are replaced'
vsx admin security unexcept security.second_factor
Over the API, PUT /api/v1/tenants/<id>/security/exceptions/<control> with {"reason": "…", "until": <Unix milliseconds>} grants one, and DELETE on the same path ends it. The control is a setting’s name, or one of dmarc-reject, mta-sts-enforce, second-step-everyone and second-step-administrators. Each change is recorded in the audit log. Only the organisation’s administrators can grant or end one.
Security report
An auditor, or a customer’s security questionnaire, often asks how the organisation stands against a framework. The security report prints the same controls under the clauses of one:
- NIS2: the cybersecurity measures of Article 21(2) of the NIS2 Directive, points (a) to (j);
- ISO 27001: the ISO/IEC 27001:2022 Annex A controls that the organisation’s settings serve, such as secure authentication (A.8.5), logging (A.8.15) and use of cryptography (A.8.24);
- BSI TR-03108: the 16 requirements of the BSI’s Secure E-Mail Transport guideline, version 2.0, each under its own title with its MUST or SHOULD. The server checks inbound MTA-STS (TR-03108-07-R) itself. Each of the others is listed as not checked by the server, with the reason, such as the listeners’ TLS, DANE or the certificates’ issuer, so you know what to show some other way.
Under each clause the report lists the controls that serve it, each with its value now or what its check found, and how many of those Strict asks about meet it. For NIS2 it also names the points that no setting of the organisation shows, such as business continuity, so you know to cover them in your own documents. The report shows how your controls stand. It does not say that you meet a framework.
Use the Security report card on Settings, which shows both frameworks, or run these with the vsx shell function from the Quick start:
vsx admin security report nis2
vsx admin security report iso-27001
vsx admin security report bsi-tr-03108
Over the API, call GET /api/v1/tenants/<id>/security/report?framework=nis2, framework=iso-27001 or framework=bsi-tr-03108. Administrators and auditors can read it.
A signed report
A report can be handed to somebody who checks it without trusting whoever passed it on. Download signed JSON on the card, --signed, or &signed=true gives the report with when it was made, for which organisation, against which framework and by which node, signed with the server’s own signing key:
vsx admin security report nis2 --signed report.json
vsx admin security verify-report report.json
verify-report checks the signature against this server’s key. Anybody else can check it too: the file holds the signature as a JWS and the address of the server’s published keys (/.well-known/jwks.json). Its header names the key, and the signature is Ed25519 over the first two parts. A report and a sign-in token can never be taken for each other.
Every setting’s default is its strictest value: a setting an organisation has not chosen reads as the strictest, and its administrators can change it within the installation’s floors. Two are the exceptions, each for a reason: mta_sts.mode defaults to testing, since enforce would stop mail from senders the moment a certificate goes wrong, and approvals are required by default only once an organisation has two administrators, since one cannot approve their own requests.
An organisation that had left a setting unset when these defaults became the strictest now reads it as the strictest. versealx-server doctor names each such organisation and the settings that changed for it, and so does its security posture (unsetNowStricter); setting the old value back keeps things as they were.
Every setting
| Key | Scope | Takes | Default | What it does |
|---|---|---|---|---|
mta_sts.mode | Tenant or server | none, testing or enforce | [mta_sts] mode in the file, or testing | The MTA-STS policy this tenant’s verified domains publish. Start at testing and read the TLS reports; enforce makes senders refuse to deliver over anything but verified TLS. |
mta_sts.max_age | Tenant or server | Seconds, 86400 to 31557600 | 604800 (a week) | How long senders may cache the policy. |
outbound.unproved_domains | Tenant or server | refuse or allow | refuse | Whether an account may send to outside addresses from a domain not yet proved to belong to the tenant. refuse accepts such mail only for recipients this server hosts. allow sends anyway, for a deployment with no DNS of its own; that mail will fail DKIM and SPF alignment at the other end. |
outbound.plaintext_retry | Tenant or server | never or after-failed-handshake | never | What to do when a TLS handshake fails on a connection where TLS was only opportunistic. never defers the message. after-failed-handshake delivers it again without encryption. A connection under DANE, MTA-STS or REQUIRETLS is never retried without encryption. |
passwords.min_length | Tenant or server | 12 to 128 | 14 | The fewest characters a password set here may have, whoever sets it: the person, an administrator’s reset, an invitation or SCIM. It can only be raised. A password already set is not asked again. |
passwords.refuse_breached | Tenant or server | off, on or strict | strict | Whether a password that has appeared in a data breach is refused wherever it is chosen. on takes a password the check cannot be made for; strict refuses new passwords until it can be. See Passwords that have leaked. |
security.second_factor | Tenant or server | off, optional or required | required | Who is asked for a code from an authenticator app after the password on the server’s sign-in pages: whoever has added one, everybody, or nobody. Wherever it is asked, mail apps sign in with app passwords. See Two-step sign-in. |
security.passkeys | Tenant or server | allowed or off | allowed | Whether people may add passkeys and sign in with them. See Passkeys. |
security.passwordless | Tenant or server | off or allowed | off | Whether somebody may sign in with only a passkey, and no password, on the server’s own pages. Never where passkeys are off. |
security.user_verification | Tenant or server | preferred or required | required | Whether a passkey must have the device check it is the person every time it is used. Signing in with only a passkey always requires it. |
security.admin_second_factor | Tenant or server | passkey or any | passkey | How the organisation’s administrators take their second step on the server’s sign-in pages: with a passkey and nothing else, or any way anybody may. See Administrators sign in with a passkey. |
forwarding.external | Tenant or server | approval, allowed or off | off | Whether a person’s mail may be forwarded outside the organisation: once an administrator approves it, always, or never. What an administrator set goes whatever this says. See Forwarding outside the organisation. |
forwarding.destinations | Tenant or server | A list of domains and addresses, at most 1000 | Empty | Destinations anybody may forward to without waiting: a domain (not its subdomains) or a single address. |
sending.hold_multiple | Tenant or server | 0 to 1000 | 10 | Hold an account’s sending for review when it sends to more recipients in a day than this many times its usual day, and to more than 200. 0 turns this sign off. See Sending held for review. |
sending.hold_new_recipients | Tenant or server | 0 to 100000 | 100 | Hold it when it writes to more than this many recipients in an hour that it had never written to. 0 turns this sign off. |
sending.hold_spam_scores | Tenant or server | 0 to 1000 | 3 | Hold it when the outbound filter scores its mail as spam this many times in an hour. 0 turns this sign off. |
sending.hold_complaints | Tenant or server | 0 to 1000 | 5 | Hold its sending outside the organisation when the feedback loops you trust complain about its mail this many times in an hour. 0 turns this sign off. |
ceilings.complaints_per_thousand | Tenant or server | 0 to 1000 | 1 | Complaints in a day per thousand messages sent outside that day, counted from a thousand, past which outside mail is deferred; 0 turns it off. See An organisation’s sending ceilings. |
offboarding.on_deprovision | Tenant or server | offboard or disable | offboard | What SCIM deactivating or deleting somebody, or a directory sync finding they have gone, does: offboard them with the organisation’s default choices, or only disable the account. See When somebody leaves. |
sieve.redirects_per_message | Tenant or server | 0 to 100 | 4 | Places one message may be redirected to by a person’s Sieve script. See Limits the organisation sets. |
sieve.redirects_per_day | Tenant or server | 0 to 100000 | No ceiling | Messages one person’s scripts may redirect in a day. |
sieve.vacations_per_day | Tenant or server | 0 to 100000 | No ceiling | Automatic replies one person may send in a day. |
sieve.script_bytes | Tenant or server | Bytes, 1024 to 1048576 | 65536 | How large one Sieve script may be. |
sieve.steps | Tenant or server | 100 to 10000000 | 100000 | Commands and tests one run of a script may evaluate. |
quarantine.keep_days | Tenant or server | Days, 1 to 3650 | 30 | How long quarantined mail waits to be released before it is deleted. A tenant’s value wins over the server’s. |
filtering.impersonation_level | Tenant or server | strict, standard or off | strict | What an impersonation finding does: quarantine the message, file it in Junk, or only add the header. See Impersonation. |
filtering.external_subject_tag | Tenant or server | on or off | on | Whether [External] is added, once, to the subject of mail from outside the organisation in the copy filed in each mailbox. |
protection.auto_takeback | Tenant or server | on or off | off | Whether the server takes a reported message back from everybody without waiting for an administrator, when enough people report it as phishing or a later verdict finds it. Only unread copies are taken, into quarantine. See Reported phishing. |
protection.takeback_reports | Tenant or server | 2 to 100 | 3 | How many different people must report one message before the server takes it back, when protection.auto_takeback is on. |
calendar.auto_add_external | Tenant or server | authenticated or never | never | Whether an invitation from outside the organisation, proved to come from its organiser, is added to the invited person’s calendar by itself. See Calendars and scheduling. |
calendar.freebusy | Tenant or server | organisation or nobody | nobody | Who in the organisation may see when a person is busy. See Busy time. |
calendar.publishing | Tenant or server | details, freebusy or off | off | What a calendar a person publishes as a link shows, or nothing. See Publishing a calendar. |
sharing.personal | Tenant or server | allowed or off | off | Whether people may share one of their own folders with a colleague from their mail app. See Sharing one folder. |
classifier.mode | Tenant or server | on or off | on | Whether the classifier learns from people moving mail into and out of Junk, and scores incoming mail by it. off keeps what it learnt. |
mdn.send | Tenant or server | off, inside, any | off | Where the organisation’s people may send read receipts: nowhere, only to the organisation’s own domains, or to anybody who asks for one. Each receipt is still a person’s own choice, sent from their mail app. |
classifier.lessons_per_day | Tenant or server | 1 to 100000 | 200 | How many messages one person may teach the classifier in a day by moving mail into and out of Junk. Moves beyond that are ignored, so one person cannot outweigh everyone else. |
sender_history.points | Tenant or server | 0 to 10 | 3 | How many points what your people made of a sender’s earlier mail may add to a message’s spam score, or take away. 0 turns it off. See Sender history. |
quarantine.digest | Tenant or server | daily or off | daily | Whether each person gets a daily message listing what was newly quarantined for them, with a link to release it. A tenant’s value wins over the server’s. |
trace.keep_days | Tenant or server | Days, 1 to 3650 | [admin] trace_days in the file, or 30 | How long a message trace is kept. An organisation’s value wins over the server’s. |
audit.keep_days | Tenant or server | Days, 90 to 36500 | 2555 (seven years) | How long the organisation’s audit log is kept. The server’s value is the least any organisation keeps: an organisation’s shorter one is read as the server’s. Older lines are removed once they are sealed into the log’s chain, and one line says which were removed. |
changes.keep_days | Server | Days, 1 to 3650 | 30 | How long each account’s record of changes is kept, which mail apps read to learn what changed since they last looked. An app away for longer reads the account again. See Changes and resynchronising. |
changes.keep_count | Server | 1000 to 10000000 | 100000 | The fewest changes kept per account, however old, so a quiet account keeps more than its last 30 days. |
deliverability.feedback_id | Tenant or server | off, bulk or all | bulk | Which mail carries a Feedback-ID for Gmail’s Postmaster Tools. See Feedback-ID for Gmail. |
deliverability.feedback_senders | Server | A list of addresses | Empty | The feedback loops whose complaint reports count, each by the exact address its reports come from. See Complaints from feedback loops. |
posture.trusted_issuers | Server | A list of up to 32 issuers, each as its distinguished name | Empty | The certificate issuers the security posture counts as meeting BSI TR-03108 12-R. Empty, the issuer is not checked. |
limits.message_size | Server | Bytes, 1024 to 26214400 | [limits] message_size in the file, or 26214400 | The largest message accepted, advertised as SIZE. It can never be raised past 25 MiB. |
limits.recipients | Server | 1 to 100 | [limits] recipients in the file, or 100 | Recipients one message may name, advertised as LIMITS RCPTMAX. |
oauth.console.redirect_uris | Server | A list of one or more addresses | https://localhost/callback | Every address the versealx-console sign-in application may be sent back to. Each must be absolute, carry no fragment, and use https, or http on a loopback address. Addresses are matched whole and exactly. |
outbound.destination.<domain>.connections | Server | 1 to 1024 | The file’s [outbound] ceiling, or the built-in one | Connections this node opens to one destination at once. |
outbound.destination.<domain>.messages_per_connection | Server | 1 to 10000 | As above | Messages sent down one connection before another is opened. |
outbound.destination.<domain>.messages_per_minute | Server | 0 to 1000000 | As above | Messages a minute to the destination. 0 lifts the ceiling. |
outbound.destination.<domain>.recipients_per_minute | Server | 0 to 1000000 | As above | Recipients a minute to the destination. 0 lifts the ceiling. |
A key that is not in this table is refused. In the outbound.destination keys, <domain> is a destination written as the file writes one — gmail.com, or .google.com (with its leading dot) for everything under a domain — using the ASCII form of the name and at least two labels.
When a change takes effect
| Setting | Read |
|---|---|
limits.message_size, limits.recipients | Every second. A new connection is told the new ceiling; a connection already open keeps the ceiling it was promised. |
outbound.destination.* | Every second, on the next delivery pass. |
mta_sts.mode, mta_sts.max_age | Within a minute. |
outbound.unproved_domains | On the next message. |
outbound.plaintext_retry | On the next delivery attempt. |
passwords.min_length, passwords.refuse_breached | On the next password set. |
security.second_factor, security.passkeys, security.passwordless, security.user_verification | At the next sign-in. Whether any organisation allows signing in with a passkey alone is read within 30 seconds. |
forwarding.external, forwarding.destinations | On the next message. |
sending.hold_multiple, sending.hold_new_recipients, sending.hold_spam_scores | On the next message sent. |
quarantine.keep_days | At the next expiry pass, which runs every ten minutes. |
protection.auto_takeback, protection.takeback_reports | On the next report, and on the next pass of the later-verdict check, every ten minutes. |
calendar.auto_add_external, calendar.freebusy, calendar.publishing | On the next invitation or busy-time question. |
sharing.personal | At the colleague’s next command. |
classifier.mode | On the next message, and for learning within seconds. |
mdn.send | On the next receipt. |
classifier.lessons_per_day | On the next move learnt. |
sender_history.points | On the next message. |
quarantine.digest | At the next digest pass, which runs every ten minutes; each person gets at most one digest a day. |
trace.keep_days | At the next trace expiry, which runs every ten minutes. |
audit.keep_days | At the next housekeeping pass, which runs every ten minutes. |
changes.keep_days, changes.keep_count | At the next housekeeping pass, which runs every ten minutes and works through the accounts a page at a time, so a large server is covered over several passes. |
oauth.console.redirect_uris | On the next sign-in request. |
Reading settings
These examples use the vsx shell function from the Quick start, which runs versealx-server as the service user with the configuration path.
vsx admin get tenants/0/settings
{
"by": "operator 0/-",
"settings": {
"limits.message_size": 10485760,
"outbound.destination.outlook.com.messages_per_minute": 120
},
"updated_at": 1789387200000,
"version": 3
}
| Field | Meaning |
|---|---|
version | How many times the document has been written. 0 means never. |
updated_at | When it was last written, in Unix milliseconds. |
by | Who wrote it, as <role> <tenant>/<account>. The operator on the local socket is operator 0/-. |
settings | The settings, by key. |
versealx-server explain prints what the configuration file says; admin get tenants/0/settings prints what the store says over it.
Changing some settings
PATCH changes the keys you give and leaves the rest. A key set to null is removed.
# Lower the message size ceiling for the whole server
vsx admin patch tenants/0/settings limits.message_size=10485760
# Remove it again, so the file's value applies
vsx admin patch tenants/0/settings limits.message_size=null
# Slow one destination down during an incident
vsx admin patch tenants/0/settings outbound.destination.outlook.com.messages_per_minute=120
# Everything under google.com: note the two dots
vsx admin patch tenants/0/settings outbound.destination..google.com.connections=4
# Enforce MTA-STS for tenant 1's domains
vsx admin patch tenants/1/settings mta_sts.mode=enforce
The answer is the new document. A patch that changes nothing returns the current document and writes nothing.
To make sure nobody else changed the document since you read it, include the version you read. The write is refused with 409 Conflict if it is no longer current:
vsx admin patch tenants/1/settings mta_sts.mode=enforce version=3
Replacing the whole document
PUT replaces the document with the one you send, which is how you import an exported document or put back an earlier version:
vsx admin put tenants/1/settings 'settings={"mta_sts.mode":"enforce","mta_sts.max_age":1209600}' version=3
Keys you leave out of settings are removed.
Exporting and importing
- Export:
vsx admin get tenants/1/settings > tenant-1-settings.json. - Keep the file in version control if you like.
- Import: take the
settingsobject from the file and send it withPUT, as above. Includeversionto avoid overwriting a change made in the meantime.
History
vsx admin get tenants/1/settings/history limit=5
vsx admin get tenants/1/settings/history/2
The first lists versions newest first, each with version, updated_at and by — 20 unless you give limit, at most 200. The second returns one earlier version whole, ready to PUT back.
Errors
| Status | Detail | Cause |
|---|---|---|
| 400 | `<key>` is not a setting | A key the server does not read, often a typing mistake. |
| 400 | `<key>` is set for the whole server, not per tenant | A Server key in a tenant’s document. Set it in tenant 0. |
| 400 | `<key>` takes one of none, testing, enforce | A value outside the allowed words. |
| 400 | `<key>` takes a whole number from <min> to <max> | A number out of range, or not a number. |
| 400 | `oauth.console.redirect_uris` takes a list of one or more absolute addresses with no fragment: https anywhere, http only on loopback | An address that cannot be registered. |
| 400 | `settings` must be an object of settings by key | A PUT without a settings object. |
| 400 | `version` must be a whole number | A version that is not a number. |
| 403 | that belongs to another tenant | Anyone but the operator writing to tenant 0 or another tenant. |
| 409 | version <n> is no longer current; it is <m> now. Read it again and retry. | Someone wrote the document after you read it. |
Something unclear or out of date on this page? Tell us.