Sovite reads one TOML file:
$SOVITE_CONFIG, if set- otherwise
/etc/sovite/sovite.toml
Validate a file without starting the MTA:
sovitectl config check # default path
sovitectl config check ./sovite.toml # explicit path
The command exits with 0 if the file is valid. Otherwise it prints every problem with the path of the bad key and exits with 1:
./sovite.toml: queue.directory: "spool" is not an absolute path
./sovite.toml: log.level: expected one of "debug", "info", "notice", "warning", "error", got "loud"The MTA runs the same validation at startup and will not start with an invalid file. Unknown keys are errors, so typos are caught instead of being silently ignored.
Every setting is optional. A release ships a commented example at etc/sovite.toml.example.
[server]
| Key | Type | Default | Description |
|---|---|---|---|
hostname | hostname | system hostname | Fully qualified name used in the SMTP greeting, EHLO, and Received: headers. Set it explicitly: the system hostname is often not fully qualified. |
authserv_id | hostname | hostname | The name in the Authentication-Results: fields this server adds (RFC 8601). Fields with this name that arrive with a message are removed. Use the same value on all servers of one site. |
shutdown_timeout | duration | 30s | How long a stopping server waits for SMTP sessions to finish their transaction and for running deliveries to end, before it cuts them off. See Operations. |
[database]
Sovite keeps its own data (login users and the addresses they may send as) in a database, managed with Ecto. The schema is created and upgraded automatically at startup.
| Key | Type | Default | Description |
|---|---|---|---|
adapter | sqlite | postgres | mysql | sqlite | SQLite needs no server: the database is one file. Use PostgreSQL or MySQL to share users between several Sovite servers. |
path | absolute path | /var/lib/sovite/sovite.db | The SQLite file. Its directory is created if missing; the file gets mode 0600. |
url | URL | unset | For postgres and mysql, required: postgres://user:password@host/database or mysql://user:password@host/database. |
pool_size | integer | 5 | Database connections. |
ssl | boolean | false | For postgres and mysql: connect with TLS, verifying the server's certificate against the system CAs. |
Sovite releases include the drivers for all three. (When Sovite is used as a library, postgrex and myxql are optional dependencies; sovitectl config check says so if one is missing.)
[queue]
| Key | Type | Default | Description |
|---|---|---|---|
directory | absolute path | /var/spool/sovite | Spool directory, created with mode 0700 if missing. Must be owned by the Sovite user. See the queue below. |
max_lifetime | duration | 5d | How long to keep trying a message. Recipients still not delivered after this are bounced (RFC 5321 §4.5.4.1 asks for at least 4–5 days). |
min_backoff | duration | 5m | Wait after the first failed attempt. The wait doubles after each attempt, with ±10% jitter. |
max_backoff | duration | 1h | Longest wait between attempts. Must not be less than min_backoff. |
delay_warning | duration | unset | Tell the sender once when a message is still not delivered after this long, for example "4h". Unset: no delay warnings. |
The queue
Each message is one file in one of these directories:
| Directory | Contents |
|---|---|
incoming/ | Accepted, not yet picked up for delivery. |
active/ | Being delivered. |
deferred/ | Waiting for the next attempt. |
hold/ | Held: not delivered until moved back to incoming/. |
corrupt/ | Failed the checksum or could not be read. Kept for inspection, never delivered. |
tmp/ | Being received. Cleaned at startup. |
Delivery results are appended to the message's file and fsynced as they come in. If Sovite stops for any reason, even kill -9 or a power failure, messages in active/ go back to incoming/ at startup and only the recipients without a recorded result are tried again. A recipient whose delivery was in progress may receive the message twice, which SMTP allows; a message is never lost.
[[listener]]
An array of tables: one per address and port to accept mail on. Without any [[listener]] table, Sovite listens on 0.0.0.0:25. Write listener = [] (before the first table) to listen nowhere.
[[listener]] # MX: mail from the Internet
address = "0.0.0.0"
port = 25
[[listener]] # mail clients, STARTTLS (RFC 6409)
address = "0.0.0.0"
mode = "submission"
[[listener]] # mail clients, implicit TLS (RFC 8314)
address = "0.0.0.0"
mode = "submissions"| Key | Type | Default | Description |
|---|---|---|---|
address | IP address | 0.0.0.0 | Address to bind. IPv6 listeners are IPv6-only, so add both 0.0.0.0 and :: for dual stack. |
mode | smtp | submission | submissions | lmtp | smtp | What the listener is for, which sets the defaults below. |
port | integer | 25 / 587 / 465 / 24 | TCP port, by mode. Ports below 1024 need CAP_NET_BIND_SERVICE or socket activation, see Security. |
auth | boolean | false / true / true | Offer AUTH. It is only offered over TLS, unless auth.plaintext is set. |
require_tls | boolean | false / true / true | Refuse MAIL, RCPT, DATA, VRFY, and AUTH with 530 5.7.0 until the client has sent STARTTLS. Do not set it on port 25: senders on the Internet may not support TLS. |
require_auth | boolean | false / true / true | Refuse MAIL with 530 5.7.0 until the client has authenticated. Needs auth = true. |
screen | boolean | true on smtp, else false | Run the postscreen-like checks of [screen] and greylisting on clients outside smtp.trusted_networks. |
tls_min_version | "1.2" | "1.3" | tls.min_version | Override for this listener. |
tls_ciphers | array of cipher names | tls.ciphers | Override for this listener. |
proxy_protocol | boolean | false | Read a PROXY protocol header (v1 or v2) from each connection, for listeners behind HAProxy or a load balancer, see Proxies. |
milters | array of names | every [[milter]] | The milters this listener runs. Default: all of them, except on lmtp and reinjection listeners. |
content_filter | transport | smtp.content_filter | Where messages received here go before delivery, see Content filters. "" for none. |
reinjection | boolean | false | The listener a content filter sends mail back to: no content filter, milters, screen, or email authentication (they ran when the mail first arrived). |
STARTTLS is offered on smtp and submission listeners whenever a certificate is configured in [tls]. A submissions listener starts TLS right after the connection opens, so it needs a certificate.
An lmtp listener speaks LMTP (RFC 2033) instead of SMTP, for use behind another MTA that hands mail over to Sovite: the client greets with LHLO, and gets one reply per recipient after the message. Its defaults are those of smtp (no AUTH, no required TLS), and it must not use port 25. Relay control and restrictions apply as on any other listener.
To let mail clients find the submission ports by themselves (RFC 6186), publish SRV records such as _submissions._tcp.example.com. SRV 0 1 465 mx.example.com. and _submission._tcp.example.com. SRV 10 1 587 mx.example.com.
[tls]
Certificates for STARTTLS and implicit TLS, and the TLS settings for all listeners. Only TLS 1.2 and 1.3 are enabled (RFC 8996), and only forward-secret AEAD cipher suites (BCP 195, RFC 9325): ECDHE with AES-GCM or ChaCha20-Poly1305. The server chooses the cipher, and renegotiation started by a client is refused.
[[tls.certificate]]
cert_file = "/etc/sovite/tls/mx.example.com.pem" # certificate, then intermediates
key_file = "/etc/sovite/tls/mx.example.com.key"
[[tls.certificate]] # another name, or an RSA twin of the one above
cert_file = "/etc/sovite/tls/mail.example.org.pem"
key_file = "/etc/sovite/tls/mail.example.org.key"| Key | Type | Default | Description |
|---|---|---|---|
certificate | array of tables | [] | Certificate chains and keys, PEM files: cert_file holds the certificate followed by its intermediates, key_file an unencrypted RSA, EC, or PKCS #8 key. Each key is checked against its certificate at startup. |
min_version | "1.2" | "1.3" | "1.2" | Lowest TLS version accepted. |
ciphers | array of cipher names | see below | Cipher suites in order of preference, as OpenSSL (ECDHE-RSA-AES128-GCM-SHA256) or IANA (TLS_AES_128_GCM_SHA256) names. Suites without forward secrecy or AEAD are refused. |
reload_interval | duration | 1m | How often to check the certificate files. Changed files are loaded for new connections, without a restart; a file that fails to load keeps the previous certificate in use (the error is logged). |
The default cipher list is TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256 (TLS 1.3), then ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305 (TLS 1.2). testssl.sh rates it A+ with a trusted certificate.
Choosing a certificate (SNI). A client that names the server it wants (SNI, RFC 6066) gets every certificate valid for that name; with both an RSA and an ECDSA certificate for a name, the client's preference decides. Wildcards match one label. Clients that send no name, or an unknown one, get the first certificate (and any other with exactly the same names).
[tls.acme]
Get and renew a certificate automatically from an ACME CA (RFC 8555), Let's Encrypt by default, with HTTP-01 challenges.
[tls.acme]
enabled = true
domains = ["mx.example.com", "mail.example.com"]
email = "postmaster@example.com"
accept_terms = true| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Use ACME. |
domains | array of host names | [] | Names the certificate is for. Each must resolve to this server. |
email | email address | unset | Contact for the CA account (expiry notices). |
accept_terms | boolean | false | Must be true: you agree to the CA's terms of service. |
directory_url | URL | Let's Encrypt | The CA's directory. For testing, Let's Encrypt staging is https://acme-staging-v02.api.letsencrypt.org/directory. |
storage | absolute path | /var/lib/sovite/acme | Where the account key, certificate, and key are kept (mode 0700). |
http_address | IP address | 0.0.0.0 | Address for the HTTP challenge listener. |
http_port | integer | 80 | The CA connects to port 80; change it only behind a port redirect. The listener only runs while a certificate is being ordered. |
renew_before | duration | 30d | Renew when the certificate expires within this time. Checked at startup and every 12 hours; failed attempts are retried hourly while the current certificate stays in use. |
The ACME certificate is used like the files in [[tls.certificate]], after them; with no other certificate it is the default.
[smtp]
Settings for all listeners. Limits apply per listener.
| Key | Type | Default | Description |
|---|---|---|---|
max_message_size | size | 25M | Largest accepted message, advertised with SIZE and enforced while receiving (552 5.3.4). Bytes, or a number with K, M, or G. |
max_recipients | integer | 100 | Recipients per message. Extra RCPT commands get 452 4.5.3, and the client sends the message again for the rest. |
max_connections | integer | 1000 | Concurrent connections. Extra connections get 421 4.7.0 and are closed. |
max_connections_per_ip | integer | 20 | Concurrent connections from one client address. |
max_errors | integer | 10 | Error replies before the session is closed with 421 4.7.0. |
command_timeout | duration | 5m | How long to wait for the next command (RFC 5321 §4.5.3.2.7). A number of seconds, or a number with ms, s, m, h, or d. |
data_timeout | duration | 5m | How long to wait for more message data. |
bare_line_endings | reject | normalize | reject | What to do with a bare LF or CR (one not in a CRLF pair). reject closes the session with 521 5.5.2; normalize turns it into CRLF. Either way only <CRLF>.<CRLF> ends a message, so SMTP smuggling is not possible. Use normalize only for old clients that send bare LF. |
vrfy | boolean | false | Answer VRFY from domains.local_recipients. When off, VRFY gets 252. |
trusted_networks | array of networks | [] | Clients that may relay mail to any domain, such as your own servers. Addresses or CIDR networks: ["127.0.0.1", "192.0.2.0/24", "2001:db8::/32"]. |
max_hops | integer | 50 | A message with more Received: fields than this is refused with 554 5.4.6 Too many hops: it is most likely in a mail loop (RFC 5321 §6.3). |
requiretls | boolean | true | Offer REQUIRETLS (RFC 8689) on encrypted connections, except LMTP. A message sent with it is only relayed over TLS verified with DANE or MTA-STS, see REQUIRETLS. |
smtputf8 | boolean | true | Offer SMTPUTF8 (RFC 6531): accept internationalized mail, see Internationalized mail. |
chunking | boolean | true | Offer CHUNKING (RFC 3030): clients may send the message in BDAT chunks instead of with DATA, see ESMTP extensions. |
binarymime | boolean | false | Offer BINARYMIME (RFC 3030) along with CHUNKING: accept binary messages. They can only be relayed to servers that support BINARYMIME too, and bounce otherwise, so leave this off unless your next hops do. |
dsn | boolean | true | Offer DSN (RFC 3461): clients choose which delivery status notifications they get, see ESMTP extensions. |
etrn | boolean | true | Offer ETRN (RFC 1985) on smtp listeners: a client asks for the mail queued for its relay domain to be sent now. |
mt_priority | boolean | true | Offer MT-PRIORITY (RFC 6710): message priorities from -9 to 9. |
deliverby | boolean | true | Offer DELIVERBY (RFC 2852): delivery deadlines. |
rrvs | boolean | true | Offer RRVS (RFC 7293): refuse mail for a mailbox whose owner may have changed. |
tarpit_after | integer | 3 | Error replies before the tarpit starts. |
tarpit_delay | duration | 1s | From the tarpit_afterth error reply on, each one is sent this much later, slowing down dictionary attacks and other clients that keep getting errors. |
forbid_unauth_pipelining | boolean | true | Disconnect a client with 554 5.5.0 when it sends more commands without waiting for the reply to one that must end a group (EHLO, DATA, NOOP, ..., RFC 2920 §3.1), or pipelines at all before PIPELINING was offered. Spam bots and SMTP smuggling attempts do this; real servers do not. Not on LMTP listeners. |
content_filter | transport | unset | An after-queue content filter for every listener, such as "smtp:[127.0.0.1]:10024", see Content filters. |
xclient_networks | array of networks | [] | Proxies that may use XCLIENT, see Proxies. |
xforward_networks | array of networks | [] | Content filters that may use XFORWARD. |
proxy_networks | array of networks | unset | On proxy_protocol listeners, the proxies: connections from them must start with a PROXY header, others are served directly. Unset: every connection must. |
proxy_timeout | duration | 10s | How long to wait for the PROXY header. |
HTTP requests and header lines (cross-protocol attacks) close the session with 421 4.7.0.
ESMTP extensions
CHUNKINGandBINARYMIME(RFC 3030). A client may sendBDAT <size>and the message in chunks of exactly that many bytes, the last one withBDAT <size> LAST, instead ofDATA. There is no dot-stuffing; bare line endings get thebare_line_endingspolicy all the same. A chunk is always read to its end, also when it is refused, so its content is never taken for commands. Withbinarymime,MAIL FROM:<...> BODY=BINARYMIMEannounces a binary message, which must be sent withBDAT. When delivering, Sovite usesBDATfor servers that offerCHUNKING(delivery.chunking).DSN(RFC 3461).RCPT TO:<...> NOTIFY=SUCCESS,FAILURE,DELAY(orNOTIFY=NEVER) chooses which notifications the sender gets about that recipient,ORCPT=rfc822;addressnames the address it was originally sent to,MAIL FROM:<...> RET=FULL(orHDRS) asks for the whole message (or only its header fields) back in a failure notification, andENVID=...gives an ID that notifications quote. WithoutNOTIFY, failures and delays are reported, as before. These parameters are passed on to next hops that supportDSN. A recipient delivered to a mailbox here is reported as "delivered" whenSUCCESSwas asked for, and one delivered to a server withoutDSNas "relayed". An alias that forwards to one address passes the parameters on; one that expands to several passes them on withoutSUCCESS, and is itself reported as "expanded" (RFC 3461 §5.2.7.3).MT-PRIORITY(RFC 6710).MAIL FROM:<...> MT-PRIORITY=3gives a message a priority from -9 to 9 (0 by default): messages to the same destination are sent in priority order, and the priority goes to next hops that support it. Clients that are neither trusted nor authenticated may lower it, not raise it. TheReceived:field shows it (PRIORITY 3).FUTURERELEASE(RFC 4865). On submission listeners, an authenticated client may sendMAIL FROM:<...> HOLDFOR=3600orHOLDUNTIL=2026-10-20T08:00:00Z: the message waits in the queue until then, up tosubmission.max_future_releaseahead, and itsqueue.max_lifetimestarts at its release.DELIVERBY(RFC 2852).MAIL FROM:<...> BY=3600;Rasks for delivery within an hour, or the message is returned (5.4.7); it then only goes to next hops that supportDELIVERBY, which get the time left.BY=3600;Nsends a delay notification when the hour is up instead, and a "relayed" notification when the message goes to a server withoutDELIVERBY;T(BY=3600;RT) asks for "relayed" notifications on the way.RRVS(RFC 7293).RCPT TO:<...> RRVS=2014-04-03T23:01:00Zasks that the recipient's mailbox has had the same owner since then. A mailbox here that was added later (sovitectl mailbox add) gets550 5.7.17; other local recipients, and role accounts such aspostmaster, pass. For a relayed recipient the parameter goes to the next hop, which must supportRRVS: otherwise it fails with5.7.19, or with;Cgoes on without.ETRN(RFC 1985).ETRN example.netretries the deferred mail for a relay domain now,ETRN @example.netfor it and its subdomains, as a backup MX does when the primary comes back. Only for relay domains (domains.relay, orrelayin the database), at most once a minute per domain; others get459.
Internationalized mail
Sovite handles internationalized mail (RFC 6530–6533): addresses with UTF-8 local parts such as jürgen@example.de, internationalized domain names such as bücher.example, and UTF-8 header fields.
- Receiving. A client that sends
MAIL FROM:<...> SMTPUTF8may use internationalized addresses for the sender and recipients of that message, and UTF-8 header fields. Without the parameter, such an address gets553 5.6.7. TheReceived:field sayswith UTF8SMTP(orUTF8SMTPS,UTF8SMTPSA, ...). - Domains. Domain names are checked with IDNA2008 (RFC 5890–5893) and used in their A-label form (
xn--bcher-kva.example) everywhere inside Sovite: in the queue, for routing, in DNS lookups, and in the database. Domains can be written either way in the config file and withsovitectl, sohosted = ["bücher.example"]andsovitectl alias add info@bücher.example ...work, and match mail sent to either form. Local parts are kept as they are written. - Delivering. A message is sent with
SMTPUTF8when it needs it: when an address of the transaction is internationalized, or when it came withSMTPUTF8and has UTF-8 header fields. Otherwise it goes out as plain mail. An internationalized message cannot be converted for a next hop that does not supportSMTPUTF8(RFC 6531 §3.2), so it is returned to the sender:5.6.7for internationalized addresses,5.6.9for UTF-8 header fields. - Notifications. Delivery status notifications about internationalized mail are internationalized ones (RFC 6533): a
message/global-delivery-statusreport that names internationalized addresses with theutf-8type (Final-Recipient: utf-8; jürgen@example.de), the original header fields asmessage/global-headers, and a UTF-8 explanation.
Domains in addresses can be written in U-labels in From: and other header fields (RFC 6532); DMARC looks up their A-labels.
[domains]
| Key | Type | Default | Description |
|---|---|---|---|
local | array of domains | [server.hostname] | Domains this server is the final destination for. |
relay | array of domains | [] | Domains accepted from anyone and forwarded elsewhere, for example as a backup MX. |
local_recipients | array of addresses | unset | The only addresses that exist at local domains. Unknown ones get 550 5.1.1 at RCPT time. Unset: every address at a local domain is accepted. |
Who may send what
Each recipient is checked when the client sends RCPT TO:
postmasterandabuseat a local domain, or a bare<Postmaster>, are always accepted (RFC 5321 §4.5.1, RFC 2142).- Local domain: accepted if
local_recipientsis unset or lists the address. - Relay domain: accepted.
- Any other domain: accepted only from
trusted_networksor afterAUTH, otherwise554 5.7.1 Relay access denied.
Domains are compared case-insensitively, and only the domain of the parsed address counts: tricks like user%other.example@local or source routes never relay. The default config trusts no one, so a fresh install is never an open relay.
A message is accepted with 250 2.0.0 Ok: queued as <queue ID> only after it is written and fsynced in the queue directory.
Transports
Each recipient's domain class picks a transport from [routing]: local_transport (default "local") for domains.local, mailbox_transport ("mailbox") for hosted domains, relay_transport and remote_transport ("smtp") for relay and all other domains. The transports table (sovitectl transport) can override it per address or domain.
| Transport | Delivers |
|---|---|
smtp, smtp:[host]:port | Over SMTP, see [delivery]. |
lmtp:unix:/run/dovecot/lmtp, lmtp:inet:host:24, lmtp:[192.0.2.1]:24 | Over LMTP (RFC 2033) to a mailbox server such as Dovecot, Cyrus, or Stalwart. Each recipient gets its own status code. No MX lookup, TLS, or login. |
local, mailbox | Into a Maildir folder, see [maildir]. |
pipe:name | To the command of the [pipe.name] section. |
error:5.1.1 text, retry:4.3.0 text, discard:text | Bounce, retry later, or drop. |
LMTP, Maildir, and pipe deliveries are final. A message whose Delivered-To: field already names the recipient has been here before, and fails for that recipient as a mail loop (5.4.6, RFC 9228). Maildir and pipe deliveries add Return-Path: and Delivered-To: at the top (RFC 5321 §4.4); an LMTP server adds its own.
A typical Dovecot setup:
[domains]
hosted = ["example.com"]
[routing]
mailbox_transport = "lmtp:unix:/run/dovecot/lmtp"[maildir]
Maildir delivery for the local and mailbox transports. Without a folder for a transport, its mail is deferred with 4.3.5.
| Key | Type | Default | Description |
|---|---|---|---|
local | path template | unset | Folder for the local transport, such as "/var/mail/{user}/". |
mailbox | path template | unset | Folder for the mailbox transport, such as "/var/vmail/{domain}/{user}/". |
Templates are absolute paths with {user} (the lower-cased local part without its extension), {domain}, and {address} (user@domain). An address that would not be a safe path component (with /, or . and ..) fails with 5.1.3. Folders are created as needed with mode 0700, and must be writable by the Sovite user. Messages are written to tmp/, flushed to disk, and then moved into new/.
[pipe.NAME]
Commands the pipe:NAME transport runs, once per recipient, with the message on standard input. Names may use letters, digits, _, and -.
[routing]
local_transport = "pipe:procmail"
[pipe.procmail]
command = ["/usr/bin/procmail", "-a", "{extension}", "-d", "{user}"]
sandbox = ["/usr/bin/systemd-run", "--quiet", "--pipe", "--wait", "--collect", "-p", "DynamicUser=yes"]| Key | Type | Default | Description |
|---|---|---|---|
command | array of strings | required | The program (an absolute path) and its arguments. |
sandbox | array of strings | unset | A program to run the command through, such as systemd-run or bwrap, with its arguments. The command's arguments are appended. |
timeout | duration | 10m | The command is killed after this long, and the mail is retried. |
directory | absolute path | / | Working directory. |
env | table | {} | Extra environment variables. |
trace_headers | boolean | true | Add Return-Path: and Delivered-To: at the top of the message. Turn off for commands that send the message on, such as content filters. |
The command is run directly, never through a shell: placeholders in arguments are replaced, and each argument reaches the command as it is. Placeholders: {sender}, {recipient}, {user}, {extension} (without the delimiter), {domain}, and {queue_id}. Addresses can start with -, so put -- before them if the command takes options. The environment is empty apart from PATH, env, and SENDER, RECIPIENT, USER, EXTENSION, DOMAIN, and QUEUE_ID.
The exit status decides the result, as in Postfix: 0 is delivered, 75 (EX_TEMPFAIL) and other temporary sysexits.h codes are retried, 67 (EX_NOUSER) and other permanent codes bounce, as does any other status. A command killed by a signal is retried. The first line of its output goes into the bounce.
Sovite cannot switch users, so the command runs as the Sovite user unless sandbox changes that.
[delivery]
Outbound delivery over SMTP, for the smtp transport.
| Key | Type | Default | Description |
|---|---|---|---|
relayhost | relay host | unset | Send all outbound mail through this server (a "smart host"). "host" or "host:port" delivers to the MX hosts of host; "[host]", "[host]:port", "[192.0.2.1]", or "[2001:db8::1]:587" delivers to that host directly. The port defaults to 25. Unset: deliver to each recipient domain's MX hosts. |
max_deliveries | integer | 100 | Deliveries in progress at once. |
destination_concurrency | integer | 20 | Deliveries in progress at once to one destination (a recipient domain, or the relay host). |
destination_rate_delay | duration | unset | Wait this long between two deliveries to the same destination, for receivers that limit how fast they accept mail. |
max_recipients | integer | 50 | Recipients per SMTP transaction. Messages with more recipients at one destination are sent in several transactions. |
max_addresses | integer | 5 | Server addresses tried per delivery attempt, across all MX hosts. |
ip_versions | array of ipv6 | ipv4 | ["ipv6", "ipv4"] | IP versions to deliver over, in order of preference. When an MX host has both, the first version is tried first and the other is the fallback. Use ["ipv4"] on hosts without IPv6 connectivity. |
connect_timeout | duration | 30s | How long to wait for a TCP connection. The SMTP protocol timeouts are those of RFC 5321 §4.5.3.2 (5 minutes for most replies, 10 minutes after the message data). |
tls | none | may | encrypt | verify | dane | dane | TLS for outbound connections, see Outbound TLS. |
tls_policy | table | {} | Per-destination levels, overriding tls: { "example.com" = "verify", "[192.0.2.1]" = "encrypt" }. Keys are recipient domains, the relay host's name, or address literals. |
tls_ca_file | absolute path | system CAs | PEM file of CAs to trust for verify. |
relayhost_username | string | unset | Log in to the relay host with SASL (SCRAM-SHA-256, PLAIN, or LOGIN, whichever it offers first). Credentials are only sent over TLS. |
relayhost_password | string | unset | Password for relayhost_username. |
source_address | array of IP addresses | [] | Connect from these local addresses: at most one IPv4 and one IPv6 address. The sender relays table (sovitectl sender-relay) can give senders their own. |
ip_pool | name | unset | Send from the addresses of this IP pool, instead of source_address. A tenant's pool comes first. |
connection_cache | boolean | true | Keep a connection open after a delivery, for the next message to the same destination, as Postfix's connection cache does. A delivery that ends with another message ready for the destination always reuses its connection. |
connection_cache_time | duration | 2s | How long an idle connection is kept. It counts towards destination_concurrency and max_deliveries meanwhile, but is closed at once when another destination waits for a free slot. With cluster.destination_affinity, one node sends all of a destination's mail, so the cluster shares its connections. |
chunking | boolean | true | Send messages in BDAT chunks (RFC 3030) to servers that offer CHUNKING. Binary messages always are, and only go to servers that offer BINARYMIME (5.6.3 otherwise). |
How mail is delivered
For each recipient domain, Sovite looks up the MX records, tries the hosts from the best preference down (hosts with the same preference in random order), and each host's addresses in ip_versions order. A domain without MX records is its own mail host. A connection failure, a rejected greeting, or a connection lost before the message is sent moves on to the next address.
| Result | What happens |
|---|---|
| 2xx after the message | Delivered. |
| 4xx, no reachable host, DNS failure | Deferred and retried with backoff, until queue.max_lifetime. |
| 5xx | Bounced: the sender gets a delivery status notification. |
| Domain does not exist, or publishes a Null MX (RFC 7505) | Bounced without trying. |
| The best MX host, or the server answering, is this server | Bounced as a mail loop (5.4.6). |
Recipients at the same destination share a transaction, and other recipients of the same message are unaffected by one recipient's result. An idle connection is reused for the next message to the same destination.
Outbound TLS
| Level | What happens |
|---|---|
none | Never use TLS. |
may | Opportunistic TLS (RFC 7435): STARTTLS when the server offers it, without checking its certificate. If the handshake fails, the address is tried again without TLS, so mail always gets through. |
encrypt | TLS is required, but the certificate is not checked. Protects against passive eavesdropping only. |
verify | TLS is required, and the certificate must be valid for the MX host (or relay host) name, from a trusted CA. MX names come from DNS, so without DNSSEC an attacker who can forge DNS answers can still redirect mail. |
dane | DANE (RFC 7672): when DNSSEC validated an MX host's MX and address records and it has TLSA records, TLS is required and the certificate must match them (DANE-EE or DANE-TA records); otherwise as may. A host whose TLSA lookup fails is skipped. DANE only works with a validating DNS resolver you trust, see [dns]; without one, this is the same as may. |
With may and dane, mail to a domain's MX hosts also follows the domain's MTA-STS policy (RFC 8461), see [mta_sts]. DANE wins: for an MX host with usable TLSA records, the MTA-STS policy is not used.
When TLS is required and cannot be used, that address is skipped (4.7.4 not offered, 4.7.5 handshake or certificate failure) and the message is retried later. A relay host on port 465 is reached with implicit TLS. Delivery log lines show the TLS version and cipher (tls=TLSv1.3 with cipher ...).
A message with a TLS-Required: No header field (RFC 8689 §5) asks to be delivered even when TLS fails: DANE and MTA-STS policies are ignored for it, and it is sent with may. Levels set in tls and tls_policy other than dane still apply.
REQUIRETLS
A client that sends MAIL FROM:<...> REQUIRETLS (RFC 8689) asks that its message is only ever sent over TLS with a verified certificate. Sovite then delivers it only:
- to an MX host covered by DANE, or by the domain's MTA-STS policy (in
testingmode too, which is enforced for these messages), or to the relay host with a certificate valid for its name; - over TLS, to a server that offers
REQUIRETLSitself, so it passes the requirement on.
Otherwise the message bounces with 5.7.30. The bounce is sent with REQUIRETLS too, and as always contains only the message's header fields.
[dns]
The DNS resolver for all lookups. Unset keys use the system's settings (/etc/resolv.conf).
| Key | Type | Default | Description |
|---|---|---|---|
nameservers | array of IP addresses | system's | Nameservers to ask, such as ["127.0.0.1"] for a local Unbound. |
port | integer | 53 | Their port. |
timeout | duration | 5s | Time per query. |
dnssec | auto | on | off | auto | Whether to believe the resolver's DNSSEC validation (the AD flag), which DANE needs. auto believes it only from nameservers on this host (127.0.0.1, ::1): anyone on the path to a remote resolver can forge the flag. on always believes it; use it only for a resolver on a network you trust. off never does, so DANE is never used. |
DANE needs a validating resolver on this host. With Unbound, for example:
# /etc/unbound/unbound.conf
server:
interface: 127.0.0.1
auto-trust-anchor-file: "/var/lib/unbound/root.key"and nameservers = ["127.0.0.1"] (or nameserver 127.0.0.1 in /etc/resolv.conf). At startup, Sovite checks that the resolver validates and logs a warning if not. sovitectl dns check runs the same check.
[mta_sts]
MTA-STS (RFC 8461) lets a domain say that mail to it must use TLS with a valid certificate: it publishes a TXT record at _mta-sts.<domain> and a policy at https://mta-sts.<domain>/.well-known/mta-sts.txt listing its MX hosts.
Sending. Before delivering to a domain, Sovite looks up its record, fetches the policy when the record's id changed, and keeps it in the database for the policy's max_age. If the record disappears or the fetch fails, a cached policy is still used until it expires, so an attacker who blocks DNS or HTTPS cannot turn it off. Policies apply with the may and dane levels (the default), to MX deliveries, not to relay hosts:
| Mode | What happens |
|---|---|
enforce | Only the MX hosts the policy lists are used, with TLS and a certificate valid for the host name from a trusted CA (tls_ca_file, or the system's). Otherwise the address is skipped, and mail is retried later. |
testing | Delivered as usual; problems are only reported with TLS-RPT. |
none | No policy. |
Receiving. To publish a policy for your own domains, Sovite can serve it (serve = true), or any web server can. sovitectl dns records example.com prints the policy and the TXT record; the record's id is derived from the policy, so it changes exactly when the policy does. To serve it with Sovite, point mta-sts.example.com at this server, and include that name in the TLS certificate (in tls.acme.domains, or the certificate files).
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Apply the MTA-STS policies of recipient domains. |
fetch_timeout | duration | 60s | Time to fetch a policy. |
serve | boolean | false | Serve this server's policy over HTTPS, for every mta-sts.<domain> that is asked for. Needs a TLS certificate. |
address, port | IP address, integer | 0.0.0.0, 443 | Where to serve it. |
mode | enforce | testing | none | testing | The mode of the policy served. Start with testing, read the TLS-RPT reports, then move to enforce. |
mx | array of host names | [server.hostname] | The MX hosts of the policy served. *.example.com matches one label. |
max_age | duration | 7d | How long senders keep the policy served (at most a year). |
[tls_rpt]
TLS-RPT (RFC 8460) reports tell a domain whether servers sending mail to it could use TLS. Domains ask for them with a TXT record at _smtp._tls.<domain>.
With reports = true, Sovite records the outcome of each session to a domain's MX hosts: which policy applied (DANE, MTA-STS, or none), and whether TLS worked or why not. Every report_interval it sends one report per domain that asks for them, gzipped JSON, by mail to its mailto: addresses (DKIM signed when report_from's domain has a [[dkim.key]]) and to its https: addresses. Outcomes are kept in the database until then.
| Key | Type | Default | Description |
|---|---|---|---|
reports | boolean | false | Record outcomes and send reports. |
report_interval | duration | 1d | How often reports are sent. |
report_org | string | server.hostname | The organization named in reports. |
report_from | email address | postmaster@<server.hostname> | The sender of reports. |
contact_info | string | report_from | How to reach you, in the reports. |
For your own domains, sovitectl dns records prints a TLS-RPT record that sends the reports of others to postmaster@<domain>.
Anti-abuse
Checks of clients that send mail to this server, in the order they run. None of them apply to smtp.trusted_networks, and the screen and greylisting only run on listeners with screen (by default, smtp ones).
[screen]
A postscreen-like score for each client. The client's address is looked up in the DNS lists below while the greeting is held back for greet_delay; each list it is on adds that list's weight. A client that talks before the greeting is an early talker, a typical spam bot, and adds early_talker_weight. At EHLO and MAIL, the RHSBL lists add the weights of the EHLO name and the sender's domain.
A client that reaches threshold is refused: before the greeting with 554 5.7.1 Service unavailable; client [192.0.2.1] blocked: listed by zen.spamhaus.org, and the connection is closed; at EHLO or MAIL, that command gets 554 5.7.1. A client at or below allow_threshold, such as one on a DNSWL list with a negative weight, skips the RHSBL lists and greylisting. A client that passed (no early talking, no failed lookup) is remembered for cache_time: its next connections are not delayed or looked up again.
[screen]
greet_delay = "6s"
[[screen.dnsbl]]
zone = "zen.spamhaus.org"
weight = 2
codes = ["127.0.0.[2..11]"]
[[screen.dnsbl]]
zone = "bl.spamcop.net"
weight = 1
[[screen.dnsbl]]
zone = "list.dnswl.org"
weight = -2
codes = ["127.0.[0..255].[1..3]"]
[[screen.rhsbl]]
zone = "dbl.spamhaus.org"
weight = 2
codes = ["127.0.1.[2..99]"]| Key | Type | Default | Description |
|---|---|---|---|
greet_delay | duration | unset | Hold the greeting back this long. Unset: no delay, and no early talker detection. A few seconds is enough; real servers wait 5 minutes for it (RFC 5321 §4.5.3.2.1). |
early_talker_weight | integer | threshold | Added for talking before the greeting. By default, enough to refuse the client on its own. |
threshold | integer | 1 | The score at which a client is refused. |
allow_threshold | integer | -1 | The score at or below which a client is allow-listed. |
lookup_timeout | duration | 10s | How long to wait for the DNS lists. Lists that do not answer in time add nothing. |
cache_time | duration | 1d | How long a client that passed is remembered, in memory. |
dnsbl | array of tables | [] | DNSBL and DNSWL lists of client addresses (RFC 5782). |
rhsbl | array of tables | [] | Lists of domains (RFC 5782 §2.3). |
Each list has:
| Key | Type | Default | Description |
|---|---|---|---|
zone | domain | required | The list's DNS zone. |
weight | integer | 1 | Added to the score when the client or domain is listed. Negative for allow lists. |
codes | array of strings | [] | The reply addresses that count, such as "127.0.0.2", "127.0.0.[2..11]", or "127.0.[0..255].[1;3]". Empty: any reply in 127.0.0.0/8. Add the same zone twice to weigh codes differently. |
check | array of helo, sender | both | RHSBL only: which domains to look up. |
Replies outside 127.0.0.0/8 are ignored, and 127.255.255.0/24 replies (list errors, such as Spamhaus refusing queries from public resolvers) count as failed lookups and are logged. Most lists only answer your own resolver, so use one on this host (see [dns]).
Restrictions for EHLO names and reverse DNS
These checks can be added to the [restrictions] chains (Sovite.Core.Restrictions lists all checks):
| Check | Effect |
|---|---|
require_reverse_hostname | 550 5.7.25 unless the client address has a reverse DNS (PTR) name. |
require_fcrdns | 550 5.7.25 unless that name also resolves back to the client address. Stricter. |
reject_forged_helo | 550 5.7.1 if the EHLO name is this server's host name or one of domains.local, localhost, or an address literal other than the client's address. |
require_known_helo | 550 5.7.1 unless the EHLO name has address or MX records. |
require_matching_helo | 550 5.7.1 unless the EHLO name resolves to the client address, or is its address literal. Strictest. |
When DNS fails, they reply 450 instead, so no client is refused for a temporary problem. For example:
[restrictions]
helo = ["permit_trusted", "permit_authenticated", "reject_forged_helo", "require_fqdn_helo"]
mail = ["permit_trusted", "permit_authenticated", "require_reverse_hostname"][rate_limit]
Limits over a sliding window, as "count/duration", such as "100/1h". Unset limits do not apply. Client limits count per client address (per /64 for IPv6), and authenticated clients only count against the user limits. Counts are kept in memory.
| Key | Type | Default | Description |
|---|---|---|---|
client_connections | rate | unset | New connections. Extra ones get 421 4.7.0 and are closed. |
client_messages | rate | unset | Messages (MAIL commands). Extra ones get 450 4.7.1. |
client_recipients | rate | unset | Accepted recipients. Extra ones get 450 4.7.1. |
user_messages | rate | unset | Messages per authenticated user: a sending quota. |
user_recipients | rate | unset | Recipients per authenticated user. |
[greylist]
Greylisting: a valid recipient from an unknown client network (/24 or /64) and sender gets 450 4.7.1 ... Greylisted, try again in 300s. Real mail servers try again, and are let through after delay; most spam bots never retry. Once a client network, sender, and recipient have passed, they pass at once until they go unused for max_age. Authenticated and allow-listed clients skip it. Triplets are kept in the database; if it fails, mail passes.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Greylist. Delays the first mail from every new sender by delay or more. |
delay | duration | 5m | How long a new triplet is deferred. |
retry_window | duration | 2d | How long a deferred triplet waits for its retry. |
max_age | duration | 35d | How long a passed triplet is remembered after its last use. |
[outbound]
Detects accounts that are probably compromised. Each recipient an authenticated user's message is queued for counts as sent, and each one that fails for good as failed. A user with at least min_failures failures in the last window, making up at least max_failure_percent of what they sent, is suspended for suspend_time: their MAIL commands get 550 5.7.1, and a warning is logged (outbound.suspended). Suspensions are kept in memory, so restarting Sovite lifts them. Cap the volume a user can send with the user_* limits in [rate_limit].
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Suspend users. |
max_failure_percent | integer | 50 | Failed share of recipients, in percent. |
min_failures | integer | 20 | Failures needed before a user is suspended. |
window | duration | 1h | The period counted. |
suspend_time | duration | 1h | How long a suspension lasts. |
Ecosystem
Sovite works with the tools a Postfix setup uses: milters such as Rspamd and OpenDKIM, policy servers such as postgrey, after-queue content filters such as amavis, proxies, and programs that call sendmail. sovitectl migrate postfix turns a Postfix configuration into a Sovite one, see Migrating from Postfix.
[[milter]]
Mail filters that speak Sendmail's milter protocol (version 6), as Postfix's smtpd_milters: Rspamd, OpenDKIM, OpenDMARC, ClamAV-milter, and others. Each listener runs the milters in milters (by default all of them, except on lmtp and reinjection listeners), each with its own connection per SMTP session, after Sovite's own checks of each stage.
[[milter]]
name = "rspamd"
address = "inet:127.0.0.1:11332"
default_action = "accept"
[[milter]]
name = "opendkim"
address = "unix:/run/opendkim/opendkim.sock"| Key | Type | Default | Description |
|---|---|---|---|
name | string | address | The name listeners use in milters. |
address | string | required | inet:host:port, inet:port@host (Sendmail style), inet6:port@host, or unix:/path (or local:/path). |
default_action | tempfail | accept | reject | tempfail | What happens when the milter cannot be reached, does not answer in time, or breaks the protocol: answer every later command of the session with 451 4.7.1 (or, at connect, 421), with 550 5.7.1, or go on without the milter. |
connect_timeout | duration | 30s | To connect. |
command_timeout | duration | 30s | For each reply to the connection, EHLO, MAIL, and RCPT steps. |
content_timeout | duration | 5m | For the replies to the header, body, and end of the message. |
A milter can reject the client, a command, a recipient, or the message (550 5.7.1 Command rejected, 451 4.7.1 Service unavailable - try again later, or its own reply), discard or quarantine the message (it goes to the hold queue), and at the end of the message add, insert, change, and delete header fields, add and remove recipients, change the sender, and replace the body. The milters see the message as it was received, including Sovite's Received: field, and their changes are applied in order. Sovite's own DKIM signatures and Authentication-Results: cover the message before those changes, so sign in one place only: with Sovite's [dkim] keys, or with the milter.
Milters get the macros Postfix sends by default, such as i (the queue ID), {client_addr}, {auth_authen}, and {daemon_name} (the listener).
Policy servers: [policy]
check_policy_service ADDRESS in any [restrictions] chain asks a Postfix policy server (the SMTPD access policy delegation protocol), such as postgrey or policyd-spf, with the attributes Postfix sends. ADDRESS is inet:host:port, unix:/path, or spawn:/path/to/program args... for programs that only talk on their standard input and output, as Postfix's spawn(8) runs policyd-spf (Sovite starts one per request, as its own user).
[restrictions]
rcpt = [
"permit_trusted",
"permit_authenticated",
"check_policy_service inet:127.0.0.1:10023", # postgrey
"check_policy_service spawn:/usr/bin/policyd-spf",
]The reply counts as an access rule: OK, DUNNO, REJECT, DEFER, DEFER_IF_PERMIT, 4NN/5NN replies, HOLD, DISCARD, WARN, and INFO, as well as PREPEND (a header field at the top of the message), REDIRECT address, BCC address, and FILTER transport:nexthop (a content filter for this message). DEFER_IF_REJECT counts as DUNNO.
| Key | Type | Default | Description |
|---|---|---|---|
timeout | duration | 100s | For each request. |
default_action | string | 451 4.3.5 Server configuration problem | The action when the server cannot be reached, does not answer in time, or sends something that is not an action. DUNNO lets mail through instead. |
Sovite checks SPF itself (see [spf]), so policyd-spf is only needed to keep its exact behaviour.
Content filters
An after-queue content filter, such as amavis, gets every message received on a listener with a content_filter before it is delivered, as with Postfix's content_filter: the queue sends it, with all its recipients and its original sender, to the filter over SMTP or LMTP, and the filter sends it back to a reinjection listener. Filters that offer XFORWARD are told the original client, HELO name, and queue ID; trust them with xforward_networks, so the message keeps them.
[smtp]
content_filter = "smtp:[127.0.0.1]:10024"
trusted_networks = ["127.0.0.1"] # the filter relays its mail back
xforward_networks = ["127.0.0.1"]
[[listener]]
port = 25
[[listener]] # where amavis sends mail back
address = "127.0.0.1"
port = 10025
reinjection = trueThe filter is a next hop of its own: delivery to it does not use REQUIRETLS, and a filter that is down keeps the mail in the queue as any next hop does. Messages a policy server sends to a FILTER go to that filter instead.
Proxies: PROXY protocol, XCLIENT, and XFORWARD
Behind HAProxy or a load balancer, a listener with proxy_protocol = true reads a PROXY protocol header (version 1 or 2) at the start of each connection, and treats the client it names as the client: restrictions, the screen, SPF, and logs see the real address. A connection without a valid header in smtp.proxy_timeout is closed. With smtp.proxy_networks, only connections from those addresses must send one, and others are served directly. Connection limits (max_connections_per_ip) count the proxy.
[smtp]
proxy_networks = ["10.0.0.5"] # the load balancer
[[listener]]
port = 25
proxy_protocol = true# haproxy.cfg
backend sovite
mode tcp
server mx1 10.0.0.10:25 send-proxy-v2Clients in smtp.xclient_networks may use Postfix's XCLIENT command, as the nginx mail proxy does: it names the client (ADDR, PORT, NAME, HELO, PROTO, LOGIN, ...), and the session starts over as if that client had connected; a LOGIN counts as authenticated. XFORWARD (smtp.xforward_networks) only describes the original client of the next message, for content filters.
[sendmail]
Releases include bin/sendmail, bin/mailq, and bin/newaliases for local programs and cron jobs. Link them where programs look for them:
ln -s /opt/sovite/bin/sendmail /usr/sbin/sendmail
ln -s /opt/sovite/bin/mailq /usr/bin/mailq
ln -s /opt/sovite/bin/newaliases /usr/bin/newaliases
sendmail takes the usual options (-t, -f, -F, -i/-oi, -bp, -bi, ...; see Sovite.Core.Sendmail) and sends the message over SMTP to server, waiting until it is queued, so the exit status tells whether that worked (75 when Sovite is not running). The listener must let local programs relay: add 127.0.0.1 to smtp.trusted_networks. Addresses without a domain get @origin. mailq lists the queue (as root or the Sovite user, since it reads the queue directory). newaliases does nothing: aliases are in the database.
The settings are read from the config file when the user running sendmail can read it; otherwise the defaults apply.
| Key | Type | Default | Description |
|---|---|---|---|
server | host | [127.0.0.1]:25 | The listener to send to. |
origin | domain | server.hostname | The domain for addresses without one, such as the user running sendmail. |
Migrating from Postfix
sovitectl migrate postfix reads a Postfix configuration and writes what Sovite needs to replace it. It changes nothing on the system: it only reads the Postfix files, and writes three files to the output directory.
sovitectl migrate postfix /etc/postfix --output /root/sovite
| File | Contents |
|---|---|
sovite.toml | The configuration. It passes sovitectl config check; values that need a look have a # check: comment. Mode 0600, since it can hold the relay host's password. |
import.sh | sovitectl commands that load the lookup tables into the database: aliases (alias_maps, virtual_alias_maps), mailboxes (virtual_mailbox_maps), transports, access rules (check_*_access), address rewrites (canonical_maps), BCC rules, moved users (relocated_maps), sender-dependent relays with their logins, and domains kept in tables. Run it once with sh import.sh, after installing sovite.toml. Mode 0600: relay passwords go to sovitectl on standard input. |
report.txt | What was migrated, what was ignored because Sovite does it anyway or it does not apply, and what needs attention: settings Sovite has no equivalent for, with their values and what to do instead, and steps to take by hand. It is printed too. |
The directory defaults to /etc/postfix, and the output to the current directory. Existing files are not overwritten without --force. --root DIR reads the files the configuration names, such as /etc/aliases, under DIR: use it on a copy of another server's /etc.
What it converts:
- main.cf: the host name and origin; the domain classes (
mydestination,virtual_mailbox_domains,virtual_alias_domains,relay_domains) andmynetworks; certificates and TLS versions; Dovecot SASL; outbound TLS levels and policies; the relay host with its login; transports (virtual_transport,mailbox_transport, and services of master.cf they name, includingpipe(8)commands, which become[pipe.NAME]sections); milters with their timeouts and default actions;smtpd_*_restrictions(merged into Sovite's stages, withreject_rbl_clientand the like becoming[screen]lists); policy servers, including programs master.cf runs withspawn(8); postscreen's DNS lists, threshold, and greeting delay; the content filter;XFORWARD,XCLIENT, and PROXY protocol settings; queue lifetimes, size and rate limits, and other limits and timers. - master.cf: one
[[listener]]perinetsmtpd or postscreen service and address ofinet_interfaces, with its mode (submission,submissionsforsmtpsorsmtpd_tls_wrappermode), and the-ooverrides that have listener keys:smtpd_sasl_auth_enable,smtpd_tls_security_level=encrypt,permit_sasl_authenticated, rejectlists (require_auth),smtpd_milters,content_filter(an empty one makes thereinjectionlistener of an after-queue filter), and the PROXY protocol.
Lookup tables are read from their text source files (hash:/etc/postfix/virtual reads /etc/postfix/virtual, so run postmap sources only), as are inline: and static: tables. SQL, LDAP, regexp, PCRE, CIDR, and other tables are reported, for their entries to be added by hand.
A typical Postfix with Dovecot and Rspamd migrates to a config that works as it is, apart from steps in the report such as these:
- Postfix's sockets for Dovecot (
private/auth,private/dovecot-lmtp) are in its queue directory, which goes away with Postfix: give Sovite its own listeners in Dovecot's10-master.conf, such as/run/dovecot/auth-clientand/run/dovecot/lmtp, and changeauth.dovecot.socketand the LMTP transport. - Sovite reads the TLS key as its own user, not as root.
- Programs master.cf ran as another user (
user=) run as the Sovite user, unless a[pipe.NAME]has asandbox. For policyd-spf, Sovite's own SPF check ([spf] reject_fail = true) can replace it.
[auth]
SMTP authentication (AUTH, RFC 4954) for mail clients on listeners with auth = true. An authenticated client may relay to any domain, but only from the addresses its login maps to.
| Key | Type | Default | Description |
|---|---|---|---|
backend | database | file | ldap | dovecot | database | Where users and passwords come from, see below. |
mechanisms | array of SCRAM-SHA-256 | PLAIN | LOGIN | OAUTHBEARER | by backend | SASL mechanisms to offer, in order. Default: SCRAM-SHA-256, PLAIN, LOGIN (only PLAIN and LOGIN for ldap and dovecot), plus OAUTHBEARER when [auth.oauth] is set. |
plaintext | boolean | false | Also offer AUTH on unencrypted connections. Leave it off: with it, passwords cross the network in the clear. |
max_failures | integer | 10 | Failed logins from one client address (one /64 for IPv6) within failure_window before it is banned. |
failure_window | duration | 10m | See max_failures. |
ban_time | duration | 1h | How long a ban lasts. A banned address gets 454 4.7.0 to AUTH, and 421 at connect on listeners with require_auth. Bans are kept in memory. |
failure_delay | duration | 1s | Wait before answering a failed login, to slow down guessing. After 3 failures in one session the connection is closed. |
sender_check | boolean | true | Enforce sender login maps (below). |
senders | table | {} | Extra sender addresses per login, see below. |
Sender login maps. An authenticated user may use as MAIL FROM their login (when it is an address), and any address matching a pattern listed for the login in auth.senders or added with sovitectl user sender add. A pattern is an address, @domain for every address at a domain, or * for any address. Anything else gets 553 5.7.1 Sender address rejected: not owned by user. The null sender <> is always allowed.
[auth.senders]
"alice@example.com" = ["sales@example.com", "@example.org"]
"relay-bot" = ["*"]Messages from authenticated clients are fixed up as RFC 6409 §8 allows: a missing Date: or Message-ID: is added, and the fields in submission.strip_headers are removed. Their Received: header uses ESMTPSA and shows the TLS version and cipher.
database backend
Users are kept in the database. Passwords are stored as SCRAM-SHA-256 hashes, so all three password mechanisms work. Manage them with sovitectl:
sovitectl user add alice@example.com # asks for the password
sovitectl user passwd alice@example.com
sovitectl user disable alice@example.com # or enable
sovitectl user sender add alice@example.com @example.org
sovitectl user sender remove alice@example.com @example.org
sovitectl user list
sovitectl user delete alice@example.com
User names are compared case-insensitively. The commands work whether or not the MTA is running.
file backend: [auth.file]
| Key | Type | Default | Description |
|---|---|---|---|
file.path | absolute path | unset | Users file, required. Re-read when it changes. |
One user per line, name:hash, compatible with Dovecot's passwd-file (more fields after the hash are ignored, # starts a comment). Make hashes with sovitectl hash-password, which reads the password from standard input. Supported hashes: {SCRAM-SHA-256}... (the default, works with every mechanism), $6$... / {SHA512-CRYPT} and $5$... / {SHA256-CRYPT} (only PLAIN and LOGIN), and {PLAIN}password (not recommended).
alice@example.com:{SCRAM-SHA-256}4096,dGhpc2lzc2FsdA==,...
bob@example.com:$6$rounds=5000$saltsalt$...ldap backend: [auth.ldap]
Checks passwords by binding to the directory as the user. Only PLAIN and LOGIN work.
| Key | Type | Default | Description |
|---|---|---|---|
ldap.servers | array of host names | [] | Required. Tried in order. |
ldap.port | integer | 389, or 636 for ldaps | Server port. |
ldap.security | starttls | ldaps | none | starttls | The server's certificate is verified against the system CAs. |
ldap.base | string | unset | Search base, such as ou=people,dc=example,dc=com. |
ldap.filter | LDAP filter | (mail=%u) | Finds the user's entry. %u is the login, %n the part before @, %d the part after. Values are inserted after the filter is parsed, so logins cannot inject filter syntax. |
ldap.dn_template | string | unset | Bind as this DN directly instead of searching, such as uid=%n,ou=people,dc=example,dc=com. |
ldap.bind_dn / ldap.bind_password | string | unset | Account for the search. Anonymous if unset. |
ldap.timeout | duration | 10s | For the whole check. |
An empty password is always refused, since LDAP treats it as an anonymous bind.
dovecot backend: [auth.dovecot]
Hands authentication to Dovecot's auth service, like Postfix's smtpd_sasl_type = dovecot. Dovecot then decides which users exist and checks their credentials.
| Key | Type | Default | Description |
|---|---|---|---|
dovecot.socket | string | unset | Required. Path of Dovecot's auth client socket, such as /run/dovecot/auth-client, or host:port. |
dovecot.timeout | duration | 30s | How long to wait for Dovecot. |
In Dovecot, give Sovite's user access to the socket:
service auth {
unix_listener auth-client {
mode = 0660
user = sovite
}
}Set auth.mechanisms to what Dovecot offers if it is more than PLAIN and LOGIN.
[auth.oauth]
OAUTHBEARER (RFC 7628): clients log in with an OAuth 2.0 access token from your identity provider, checked by token introspection (RFC 7662).
| Key | Type | Default | Description |
|---|---|---|---|
introspection_url | URL | unset | The provider's introspection endpoint. |
client_id / client_secret | string | unset | Credentials for the endpoint. |
username_claim | string | username | Claim of the introspection response that holds the login, such as email or preferred_username. |
required_scope | string | unset | A scope the token must have. |
[submission]
| Key | Type | Default | Description |
|---|---|---|---|
strip_headers | array of strings | ["Return-Path"] | Header fields removed from messages of authenticated clients. Add "X-Originating-IP" and similar to hide client details. |
future_release | boolean | true | Offer FUTURERELEASE (RFC 4865) on submission listeners: authenticated clients may hold messages for later, see ESMTP extensions. |
max_future_release | duration | 7d | How far ahead a message may be held. |
Email authentication
Mail from outside (clients that are neither in smtp.trusted_networks nor authenticated, on listeners other than LMTP) is checked:
- SPF (RFC 7208) when the client sends
MAIL: may the client's address send mail for theMAIL FROMdomain, and for theHELOname? At most 10 DNS-querying terms and 2 void lookups are followed, as the RFC says. - DKIM (RFC 6376) signatures, while the message is received. RSA-SHA256 and Ed25519-SHA256 (RFC 8463) are accepted; RSA-SHA1 signatures and RSA keys shorter than 1024 bits never pass (RFC 8301). At most 10 signatures per message are checked.
- ARC (RFC 8617): the chain of seals forwarders and mailing lists added.
- DMARC (RFC 7489) at the end of the data: does SPF or DKIM pass for a domain aligned with the
From:domain? The policy is found by walking up the DNS tree from theFrom:domain (as DMARCbis does), so no public suffix list is needed.
The results go into an Authentication-Results: field at the top of the message:
Authentication-Results: mx.example.org;
spf=pass smtp.mailfrom=alice@example.com;
spf=pass smtp.helo=mail.example.com;
dkim=pass header.d=example.com header.i=@example.com header.s=s2026 header.a=rsa-sha256 header.b=Xw3qPg5R;
arc=none;
dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=example.comMail from users (trusted or authenticated clients) is DKIM signed instead, with every key of [[dkim.key]] for the From: domain, or for its closest parent domain that has keys.
To publish the DNS records a domain needs, run sovitectl dns records example.com. It prints the MX, SPF, DKIM, DMARC, MTA-STS, and TLS-RPT records for this server's settings, and TLSA records for DANE when a certificate is configured. sovitectl dkim generate example.com s2026 /etc/sovite/dkim/example.com.pem makes a new key (rsa, rsa:4096, or ed25519 as a last argument) and prints its record.
[spf]
| Key | Type | Default | Description |
|---|---|---|---|
verify | boolean | true | Check SPF for mail from outside. |
helo | boolean | true | Also check the HELO name. For the null sender <>, the HELO name is always the identity checked. |
reject_fail | boolean | false | Refuse MAIL with 550 5.7.23 when SPF fails. Off by default: forwarding breaks SPF, and DMARC makes the better decision with DKIM too. |
timeout | duration | 20s | Time for the whole check. Longer checks give temperror. |
[dkim]
| Key | Type | Default | Description |
|---|---|---|---|
verify | boolean | true | Check the DKIM signatures of mail from outside. |
sign | boolean | true | Sign mail from users with the keys below. |
headers | array of strings | see below | The header fields signed, when present. Defaults to From, Reply-To, Subject, Date, Message-ID, To, Cc, In-Reply-To, References, the MIME-Version and Content-* fields, Sender, the Resent-* and List-* fields, and Autocrypt. From is signed once more than it occurs, so another From: cannot be added later. |
expiration | duration | unset | Signatures expire after this long (x=). Unset: they do not expire. |
Each [[dkim.key]] is one key:
[[dkim.key]]
domain = "example.com"
selector = "s2026r"
file = "/etc/sovite/dkim/example.com.s2026r.pem"
[[dkim.key]]
domain = "example.com"
selector = "s2026e"
file = "/etc/sovite/dkim/example.com.s2026e.pem" # an Ed25519 key: mail gets both signatures| Key | Type | Default | Description |
|---|---|---|---|
domain | domain | required | The signing domain (d=). |
selector | selector | required | The selector (s=): the record is published at <selector>._domainkey.<domain>. |
file | absolute path | required | The private key, PEM: PKCS #8 or PKCS #1, RSA (at least 1024 bits; use 2048) or Ed25519. It is read when the config is loaded, so a broken key is a config error. Keep it readable by the Sovite user only. |
sign | boolean | true | Sign with this key. Set false to keep a key in the config, and in sovitectl dns records, while it is not used. |
To rotate a key: add the new key with sign = false, publish its record, and wait for DNS caches (a day is plenty). Then set sign = true on the new key and sign = false on the old one. Remove the old key and its record a week or so later, once no mail signed with it is waiting to be checked.
[arc]
| Key | Type | Default | Description |
|---|---|---|---|
verify | boolean | true | Check the ARC chain of mail from outside. |
seal | boolean | false | ARC seal mail from outside that an alias forwards to another domain, with the key domain and selector name. The seal records this server's results, so the destination can trust them when SPF and DKIM no longer pass after forwarding. |
domain, selector | domain, selector | unset | The [[dkim.key]] to seal with. Required with seal. |
trusted_sealers | array of domains | [] | Forwarders and mailing lists whose seals you trust: a message that fails DMARC, but has an unbroken ARC chain last sealed by one of them, is not rejected or held. |
[dmarc]
| Key | Type | Default | Description |
|---|---|---|---|
verify | boolean | true | Evaluate DMARC for mail from outside. |
policy | report | enforce | report | With report, results are only recorded. With enforce, a failing message is refused with 550 5.7.26 when its domain's policy is reject, and put in the hold queue when it is quarantine (pct= and t=y are honoured). |
reports | boolean | false | Send aggregate reports (RFC 7489 §7.2) to the rua= addresses of the domains whose mail this server receives. Results are kept in the database until they are reported. Destinations outside the reported domain are only used if they publish that they accept its reports. |
report_interval | duration | 1d | How often reports are sent. |
report_org | string | server.hostname | The organization named in reports. |
report_from | email address | postmaster@<server.hostname> | The sender of reports. They are DKIM signed when its domain has a [[dkim.key]]. |
[srs]
The Sender Rewriting Scheme lets this server forward mail without breaking SPF at the destination. When an alias sends mail from outside on to another domain, that delivery uses a sender such as SRS0=HHHH=TT=example.com=alice@mx.example.org instead of alice@example.com. Bounces to it come back here, are checked, and are sent on to alice@example.com. The original sender stays in the queue: bounces from this server go to it directly, and local deliveries still see it.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Rewrite senders when forwarding. |
domain | domain | server.hostname | The domain of SRS addresses. It must be a local domain of this server, with MX records pointing here. |
secrets | array of strings | [] | Keys for the hash that makes SRS addresses impossible to forge. Required, at least 16 characters each. The first one signs; all are accepted, so to change the secret, put the new one first and drop the old one after max_age days. |
max_age | integer | 21 | Days an SRS address is accepted for bounces. |
[bounce]
Delivery status notifications (RFC 3464) are sent from MAILER-DAEMON@<server.hostname> with the null sender <>. They contain an explanation, a machine-readable report, and the headers of the original message (not its body). A failure notification about a message sent with RET=FULL (see ESMTP extensions) returns the whole message instead, unless it is larger than size_limit, binary, or was sent with REQUIRETLS. Which recipients are reported depends on their NOTIFY parameters.
| Key | Type | Default | Description |
|---|---|---|---|
double_bounce_recipient | email address | unset | Where to report a notification, or any other message from the null sender, that could not be delivered. Unset: such double bounces are only logged. A failed double-bounce report is never reported again. |
size_limit | size | 50000 | The largest message a failure notification returns whole, for RET=FULL. Larger ones get their header fields returned. |
[log]
| Key | Type | Default | Description |
|---|---|---|---|
level | debug | info | notice | warning | error | info | Minimum level written. |
format | text | json | text | text writes classic log lines; json writes one JSON object per line. See Logging. |
directory | absolute path | unset | Write logs to rotating files in this directory, created if missing. Unset: log to standard output. The keys below only apply when this is set. |
file_name | file name pattern | sovite.{date}.{n}.log | {date} is replaced with the date formatted with date_format; {n} with the file number, which starts at 1 and goes up with each rotation. {n} is required. |
date_format | strftime format | %Y-%m-%d | Format of {date} (Calendar.strftime/2). Must not produce a /. |
max_size | size | 100M | Start a new file before the current one would grow past this size. A number of bytes, or a number with a K, M, or G suffix (1024-based), such as 512M or 1G. |
rotation | never | hourly | daily | weekly | monthly | daily | Also start a new file when the hour, day, ISO week, or month changes. |
max_files | integer | 14 | Number of log files to keep, including the current one. Older files matching file_name are deleted after each rotation. 0 keeps all. |
symlink | file name | unset | Name of a symlink in directory that always points to the current file, for tail -F. |
syslog | boolean | false | Also send logs to a syslog daemon, instead of to standard output. With directory set, logs go to both. |
syslog_address | path or host:port | /dev/log | A local datagram socket (/dev/log for journald, rsyslog, or syslog-ng), or a remote daemon over UDP: logs.example.com, 192.0.2.1:514, [2001:db8::1]:514. The port defaults to 514. |
syslog_facility | mail | daemon | user | local0 … local7 | mail | The syslog facility. |
syslog_format | rfc3164 | rfc5424 | rfc3164 | The classic BSD format (<22>Oct 4 12:00:00 sovite[812]: ...), or RFC 5424. The message is the text line without its date and level, or the json object. |
[admin]
The web server: the web UI at /, the admin API at /api, which sovitectl uses to act on the running server (queue changes, reloads, status), and the Prometheus metrics at /metrics. It listens on the loopback address only, and every API request needs the token in token_file. See Operations for the requests and the protections.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Run the web server. Without it, sovitectl queue can only read the queue, and config reload, config diff, and status do not work. |
address | IP address | 127.0.0.1 | Address to listen on. |
port | integer | 9025 | TCP port. |
token_file | absolute path | /var/lib/sovite/admin.token | The bearer token. Created with a random token, mode 0600, if it does not exist. sovitectl reads it, so it must run as root or the user Sovite runs as. Wrong tokens count as failed logins, with auth.max_failures, auth.failure_window, and auth.ban_time. |
allowed_networks | array of networks | ["127.0.0.0/8", "::1"] | Clients outside these networks get 403, token or not. |
tls | boolean | false | Serve HTTPS with the server's certificates ([tls]). |
public_metrics | boolean | false | Serve /metrics without the token, to clients in allowed_networks. |
web_root | absolute path | unset | The web UI's files. Unset: priv/web in the release, the build of lib/core/sovite_web. They need no token. |
[metrics]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Serve Prometheus metrics at /metrics on the admin API. The metrics are listed in Sovite.Core.Metrics. |
# prometheus.yml
scrape_configs:
- job_name: sovite
authorization:
credentials_file: /var/lib/sovite/admin.token
static_configs:
- targets: ["127.0.0.1:9025"][tracing]
OpenTelemetry traces of SMTP sessions and deliveries, sent with OTLP over HTTP (JSON) to a collector such as the OpenTelemetry Collector, Jaeger, or Grafana Tempo. See Sovite.Core.Tracing for the spans.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Send traces. |
endpoint | URL | http://127.0.0.1:4318/v1/traces | The collector's OTLP/HTTP traces URL. https URLs are verified against the system CAs. |
headers | table of strings | {} | Extra request headers, such as { authorization = "Bearer ..." }. |
sample_ratio | number, 0 to 1 | 1.0 | The share of traces sent. All the deliveries of one message are sampled together. |
service_name | string | sovite | The service.name resource attribute. |
Clusters: [cluster]
Several Sovite servers can run as one cluster: they share their configuration, their rate limits, bans, and greylisting, keep copies of each other's queues, and deliver the mail of a node that stays down. Nodes talk over Partisan, not Distributed Erlang, on their own TCP port, with TLS 1.3 and mutual authentication. See Operations for setting one up.
[cluster]
enabled = true
node_name = "mx1@10.0.0.1"
address = "10.0.0.1"
seeds = ["mx2@10.0.0.2", "mx3@10.0.0.3"]
cert_file = "/etc/sovite/cluster/mx1.pem"
key_file = "/etc/sovite/cluster/mx1.key"
ca_file = "/etc/sovite/cluster/ca.pem"
shared_config = "/var/lib/sovite/cluster.toml"| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Run as a cluster node. Needs node_name, address, cert_file, key_file, and ca_file. |
node_name | name@host | unset | This node's name, unique in the cluster. The host part is usually the node's address. |
address | IP address | unset | The address to accept other nodes on. They are told it, so it must be one they can reach: not 0.0.0.0. |
port | integer | 4370 | The port to accept other nodes on. |
seeds | array of name@host[:port] | [] | Nodes to join, tried every join_interval until connected. Every node needs at least one other; the rest are learned from them. The port defaults to port. Host names are resolved on each try. |
cert_file | absolute path | unset | This node's certificate (PEM), signed by the cluster CA. |
key_file | absolute path | unset | Its private key. |
ca_file | absolute path | unset | The cluster CA: nodes accept only peers whose certificate it signed. Use a CA of its own, which signs nothing but the cluster's certificates. |
join_interval | duration | 10s | How often to try the seeds that are not connected. |
sync_interval | duration | 1s | How often nodes send each other what their rate limits counted, failed logins, suspended accounts, and greylist entries. |
shared_config | absolute path | unset | The cluster's shared configuration, kept up to date by the cluster: see below. |
queue_replicas | integer, 0 to 2 | 1 | Other nodes that keep a copy of each queued message. 0: no copies, so the mail of a node that is down waits for it. |
handover_after | duration | 10m | How long a node must stay down before the others deliver its messages from their copies. |
destination_affinity | boolean | false | Send all the cluster's mail for a destination (an MX domain or relay host) from one node, which keeps fewer, busier connections to it. Mail queued on another node is handed to it. |
All but seeds, join_interval, sync_interval, shared_config, queue_replicas, handover_after, and destination_affinity need a restart.
Rate limits shared by a cluster count by the wall clock, so keep the nodes' clocks in sync (NTP). Nodes with their own SQLite databases share greylist entries; nodes that share a PostgreSQL or MySQL database share every table already, which is the way to share domains, users, aliases, and the rest.
The shared configuration
With shared_config, the settings of that file come first, and those of the node's own file override them: tables are merged key by key, other values replaced. Keep in the node's file what is its own ([server], [[listener]], [cluster], [database], paths), and put the rest in the shared file. sovitectl cluster config push FILE sends a new version of it to every node; each checks it with its own file, stores it, and reloads. A node that comes back or joins later gets the newest version. The shared file cannot have a [cluster] table.
Tenants: [[tenant]]
Tenants are customers whose mail one server or cluster hosts, each with its own limits, DKIM keys, and IP pool.
[[tenant]]
name = "acme"
domains = ["acme.example"]
users = ["relay-acme"]
ip_pool = "acme"
messages = "10000/1d"
recipients = "50000/1d"
max_message_size = "10M"
max_recipients = 500| Key | Type | Default | Description |
|---|---|---|---|
name | name | required | Letters, digits, _, and -. Kept with each queued message. |
domains | array of domains | [] | The tenant's domains, with their subdomains. A domain belongs to one tenant only. |
users | array of strings | [] | Logins of the tenant, besides those in its domains (user@acme.example). |
ip_pool | name | unset | The IP pool its mail goes out from, instead of delivery.ip_pool. |
messages | rate | unset | Messages per period for the whole tenant, such as "10000/1d": 450 4.7.1 over it. |
recipients | rate | unset | Recipients per period for the whole tenant. |
max_message_size | size | unset | The largest message: 552 5.3.4 over it, at MAIL (with SIZE) or after the data. |
max_recipients | integer | unset | Recipients per message: 452 4.5.3 over it. |
A message belongs to a tenant when the client logged in as one of its users, or with a login in one of its domains, or, for clients that logged in or are in smtp.trusted_networks, when the sender's domain is one of its domains. Mail from the internet belongs to no tenant.
The DKIM keys ([[dkim.key]]) of a tenant's domains sign only that tenant's mail, so one customer cannot sign as another. Tenant limits come on top of [rate_limit], and are shared by a cluster.
IP pools: [[ip_pool]]
The source addresses mail goes out from, with warm-up schedules and daily limits for new addresses. SMTP deliveries use the pool of their tenant, else delivery.ip_pool; the sender relays table comes first.
[[ip_pool]]
name = "shared"
overflow = "spare"
[[ip_pool.address]]
address = "192.0.2.10"
helo = "mta1.example.com"
[[ip_pool.address]]
address = "192.0.2.11"
helo = "mta2.example.com"
warmup_start = 2026-10-01
warmup = [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
daily_limit = 100000| Key | Type | Default | Description |
|---|---|---|---|
name | name | required | The pool's name. |
overflow | name | unset | The pool to use when no address of this one has room left today. Without it, the delivery is deferred (4.7.0) until the next day. |
address | array of tables | required | The pool's addresses, below. |
Each [[ip_pool.address]]:
| Key | Type | Default | Description |
|---|---|---|---|
address | IP address | required | A local address of the node that has it. |
helo | host name | server.hostname | The EHLO name of connections from it: the name its reverse DNS gives. |
node | node name | this node | In a cluster, the node that has the address. Mail for a pool with no address on this node goes to a node that has one. |
warmup_start | date | unset | The first day of the warm-up schedule. The address is not used before it. |
warmup | array of integers | [] | Messages per day, from warmup_start on: the first entry for that day, the second for the next, and so on. |
daily_limit | integer | unset | Messages per day once the schedule is over, or without one. |
Each connection picks one IPv4 and one IPv6 address with room left today (UTC), the one with the most room left as a share of its limit first. A message counts once for the address it went out from. Counts are kept in the database (ip_address_counts), and shown by sovitectl ip-pool status, the web UI, and the sovite_ip_pool_sent_today metric.
Starting the MTA
The :sovite OTP application starts the MTA only when the :start_mta application env is true:
- Production releases (
MIX_ENV=prod) set it inconfig/runtime.exs. - In development:
SOVITE_START_MTA=1 SOVITE_CONFIG=./sovite.toml iex -S mix. - When Sovite is a dependency of another project, the MTA never starts on its own. To embed it, add
{Sovite.Core.Supervisor, config_path: "..."}to your supervision tree.