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]

KeyTypeDefaultDescription
hostnamehostnamesystem hostnameFully qualified name used in the SMTP greeting, EHLO, and Received: headers. Set it explicitly: the system hostname is often not fully qualified.
authserv_idhostnamehostnameThe 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_timeoutduration30sHow 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.

KeyTypeDefaultDescription
adaptersqlite | postgres | mysqlsqliteSQLite needs no server: the database is one file. Use PostgreSQL or MySQL to share users between several Sovite servers.
pathabsolute path/var/lib/sovite/sovite.dbThe SQLite file. Its directory is created if missing; the file gets mode 0600.
urlURLunsetFor postgres and mysql, required: postgres://user:password@host/database or mysql://user:password@host/database.
pool_sizeinteger5Database connections.
sslbooleanfalseFor 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]

KeyTypeDefaultDescription
directoryabsolute path/var/spool/soviteSpool directory, created with mode 0700 if missing. Must be owned by the Sovite user. See the queue below.
max_lifetimeduration5dHow 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_backoffduration5mWait after the first failed attempt. The wait doubles after each attempt, with ±10% jitter.
max_backoffduration1hLongest wait between attempts. Must not be less than min_backoff.
delay_warningdurationunsetTell 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:

DirectoryContents
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"
KeyTypeDefaultDescription
addressIP address0.0.0.0Address to bind. IPv6 listeners are IPv6-only, so add both 0.0.0.0 and :: for dual stack.
modesmtp | submission | submissions | lmtpsmtpWhat the listener is for, which sets the defaults below.
portinteger25 / 587 / 465 / 24TCP port, by mode. Ports below 1024 need CAP_NET_BIND_SERVICE or socket activation, see Security.
authbooleanfalse / true / trueOffer AUTH. It is only offered over TLS, unless auth.plaintext is set.
require_tlsbooleanfalse / true / trueRefuse 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_authbooleanfalse / true / trueRefuse MAIL with 530 5.7.0 until the client has authenticated. Needs auth = true.
screenbooleantrue on smtp, else falseRun the postscreen-like checks of [screen] and greylisting on clients outside smtp.trusted_networks.
tls_min_version"1.2" | "1.3"tls.min_versionOverride for this listener.
tls_ciphersarray of cipher namestls.ciphersOverride for this listener.
proxy_protocolbooleanfalseRead a PROXY protocol header (v1 or v2) from each connection, for listeners behind HAProxy or a load balancer, see Proxies.
miltersarray of namesevery [[milter]]The milters this listener runs. Default: all of them, except on lmtp and reinjection listeners.
content_filtertransportsmtp.content_filterWhere messages received here go before delivery, see Content filters. "" for none.
reinjectionbooleanfalseThe 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"
KeyTypeDefaultDescription
certificatearray 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.
ciphersarray of cipher namessee belowCipher 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_intervalduration1mHow 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
KeyTypeDefaultDescription
enabledbooleanfalseUse ACME.
domainsarray of host names[]Names the certificate is for. Each must resolve to this server.
emailemail addressunsetContact for the CA account (expiry notices).
accept_termsbooleanfalseMust be true: you agree to the CA's terms of service.
directory_urlURLLet's EncryptThe CA's directory. For testing, Let's Encrypt staging is https://acme-staging-v02.api.letsencrypt.org/directory.
storageabsolute path/var/lib/sovite/acmeWhere the account key, certificate, and key are kept (mode 0700).
http_addressIP address0.0.0.0Address for the HTTP challenge listener.
http_portinteger80The CA connects to port 80; change it only behind a port redirect. The listener only runs while a certificate is being ordered.
renew_beforeduration30dRenew 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.

KeyTypeDefaultDescription
max_message_sizesize25MLargest accepted message, advertised with SIZE and enforced while receiving (552 5.3.4). Bytes, or a number with K, M, or G.
max_recipientsinteger100Recipients per message. Extra RCPT commands get 452 4.5.3, and the client sends the message again for the rest.
max_connectionsinteger1000Concurrent connections. Extra connections get 421 4.7.0 and are closed.
max_connections_per_ipinteger20Concurrent connections from one client address.
max_errorsinteger10Error replies before the session is closed with 421 4.7.0.
command_timeoutduration5mHow 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_timeoutduration5mHow long to wait for more message data.
bare_line_endingsreject | normalizerejectWhat 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.
vrfybooleanfalseAnswer VRFY from domains.local_recipients. When off, VRFY gets 252.
trusted_networksarray 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_hopsinteger50A 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).
requiretlsbooleantrueOffer 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.
smtputf8booleantrueOffer SMTPUTF8 (RFC 6531): accept internationalized mail, see Internationalized mail.
chunkingbooleantrueOffer CHUNKING (RFC 3030): clients may send the message in BDAT chunks instead of with DATA, see ESMTP extensions.
binarymimebooleanfalseOffer 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.
dsnbooleantrueOffer DSN (RFC 3461): clients choose which delivery status notifications they get, see ESMTP extensions.
etrnbooleantrueOffer ETRN (RFC 1985) on smtp listeners: a client asks for the mail queued for its relay domain to be sent now.
mt_prioritybooleantrueOffer MT-PRIORITY (RFC 6710): message priorities from -9 to 9.
deliverbybooleantrueOffer DELIVERBY (RFC 2852): delivery deadlines.
rrvsbooleantrueOffer RRVS (RFC 7293): refuse mail for a mailbox whose owner may have changed.
tarpit_afterinteger3Error replies before the tarpit starts.
tarpit_delayduration1sFrom 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_pipeliningbooleantrueDisconnect 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_filtertransportunsetAn after-queue content filter for every listener, such as "smtp:[127.0.0.1]:10024", see Content filters.
xclient_networksarray of networks[]Proxies that may use XCLIENT, see Proxies.
xforward_networksarray of networks[]Content filters that may use XFORWARD.
proxy_networksarray of networksunsetOn proxy_protocol listeners, the proxies: connections from them must start with a PROXY header, others are served directly. Unset: every connection must.
proxy_timeoutduration10sHow 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

  • CHUNKING and BINARYMIME (RFC 3030). A client may send BDAT <size> and the message in chunks of exactly that many bytes, the last one with BDAT <size> LAST, instead of DATA. There is no dot-stuffing; bare line endings get the bare_line_endings policy all the same. A chunk is always read to its end, also when it is refused, so its content is never taken for commands. With binarymime, MAIL FROM:<...> BODY=BINARYMIME announces a binary message, which must be sent with BDAT. When delivering, Sovite uses BDAT for servers that offer CHUNKING (delivery.chunking).
  • DSN (RFC 3461). RCPT TO:<...> NOTIFY=SUCCESS,FAILURE,DELAY (or NOTIFY=NEVER) chooses which notifications the sender gets about that recipient, ORCPT=rfc822;address names the address it was originally sent to, MAIL FROM:<...> RET=FULL (or HDRS) asks for the whole message (or only its header fields) back in a failure notification, and ENVID=... gives an ID that notifications quote. Without NOTIFY, failures and delays are reported, as before. These parameters are passed on to next hops that support DSN. A recipient delivered to a mailbox here is reported as "delivered" when SUCCESS was asked for, and one delivered to a server without DSN as "relayed". An alias that forwards to one address passes the parameters on; one that expands to several passes them on without SUCCESS, and is itself reported as "expanded" (RFC 3461 §5.2.7.3).
  • MT-PRIORITY (RFC 6710). MAIL FROM:<...> MT-PRIORITY=3 gives 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. The Received: field shows it (PRIORITY 3).
  • FUTURERELEASE (RFC 4865). On submission listeners, an authenticated client may send MAIL FROM:<...> HOLDFOR=3600 or HOLDUNTIL=2026-10-20T08:00:00Z: the message waits in the queue until then, up to submission.max_future_release ahead, and its queue.max_lifetime starts at its release.
  • DELIVERBY (RFC 2852). MAIL FROM:<...> BY=3600;R asks for delivery within an hour, or the message is returned (5.4.7); it then only goes to next hops that support DELIVERBY, which get the time left. BY=3600;N sends a delay notification when the hour is up instead, and a "relayed" notification when the message goes to a server without DELIVERBY; T (BY=3600;RT) asks for "relayed" notifications on the way.
  • RRVS (RFC 7293). RCPT TO:<...> RRVS=2014-04-03T23:01:00Z asks that the recipient's mailbox has had the same owner since then. A mailbox here that was added later (sovitectl mailbox add) gets 550 5.7.17; other local recipients, and role accounts such as postmaster, pass. For a relayed recipient the parameter goes to the next hop, which must support RRVS: otherwise it fails with 5.7.19, or with ;C goes on without.
  • ETRN (RFC 1985). ETRN example.net retries the deferred mail for a relay domain now, ETRN @example.net for it and its subdomains, as a backup MX does when the primary comes back. Only for relay domains (domains.relay, or relay in the database), at most once a minute per domain; others get 459.

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:<...> SMTPUTF8 may use internationalized addresses for the sender and recipients of that message, and UTF-8 header fields. Without the parameter, such an address gets 553 5.6.7. The Received: field says with UTF8SMTP (or UTF8SMTPS, 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 with sovitectl, so hosted = ["bücher.example"] and sovitectl 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 SMTPUTF8 when it needs it: when an address of the transaction is internationalized, or when it came with SMTPUTF8 and 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 support SMTPUTF8 (RFC 6531 §3.2), so it is returned to the sender: 5.6.7 for internationalized addresses, 5.6.9 for UTF-8 header fields.
  • Notifications. Delivery status notifications about internationalized mail are internationalized ones (RFC 6533): a message/global-delivery-status report that names internationalized addresses with the utf-8 type (Final-Recipient: utf-8; jürgen@example.de), the original header fields as message/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]

KeyTypeDefaultDescription
localarray of domains[server.hostname]Domains this server is the final destination for.
relayarray of domains[]Domains accepted from anyone and forwarded elsewhere, for example as a backup MX.
local_recipientsarray of addressesunsetThe 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:

  1. postmaster and abuse at a local domain, or a bare <Postmaster>, are always accepted (RFC 5321 §4.5.1, RFC 2142).
  2. Local domain: accepted if local_recipients is unset or lists the address.
  3. Relay domain: accepted.
  4. Any other domain: accepted only from trusted_networks or after AUTH, otherwise 554 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.

TransportDelivers
smtp, smtp:[host]:portOver SMTP, see [delivery].
lmtp:unix:/run/dovecot/lmtp, lmtp:inet:host:24, lmtp:[192.0.2.1]:24Over 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, mailboxInto a Maildir folder, see [maildir].
pipe:nameTo the command of the [pipe.name] section.
error:5.1.1 text, retry:4.3.0 text, discard:textBounce, 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.

KeyTypeDefaultDescription
localpath templateunsetFolder for the local transport, such as "/var/mail/{user}/".
mailboxpath templateunsetFolder 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"]
KeyTypeDefaultDescription
commandarray of stringsrequiredThe program (an absolute path) and its arguments.
sandboxarray of stringsunsetA program to run the command through, such as systemd-run or bwrap, with its arguments. The command's arguments are appended.
timeoutduration10mThe command is killed after this long, and the mail is retried.
directoryabsolute path/Working directory.
envtable{}Extra environment variables.
trace_headersbooleantrueAdd 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.

KeyTypeDefaultDescription
relayhostrelay hostunsetSend 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_deliveriesinteger100Deliveries in progress at once.
destination_concurrencyinteger20Deliveries in progress at once to one destination (a recipient domain, or the relay host).
destination_rate_delaydurationunsetWait this long between two deliveries to the same destination, for receivers that limit how fast they accept mail.
max_recipientsinteger50Recipients per SMTP transaction. Messages with more recipients at one destination are sent in several transactions.
max_addressesinteger5Server addresses tried per delivery attempt, across all MX hosts.
ip_versionsarray 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_timeoutduration30sHow 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).
tlsnone | may | encrypt | verify | danedaneTLS for outbound connections, see Outbound TLS.
tls_policytable{}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_fileabsolute pathsystem CAsPEM file of CAs to trust for verify.
relayhost_usernamestringunsetLog in to the relay host with SASL (SCRAM-SHA-256, PLAIN, or LOGIN, whichever it offers first). Credentials are only sent over TLS.
relayhost_passwordstringunsetPassword for relayhost_username.
source_addressarray 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_poolnameunsetSend from the addresses of this IP pool, instead of source_address. A tenant's pool comes first.
connection_cachebooleantrueKeep 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_timeduration2sHow 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.
chunkingbooleantrueSend 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.

ResultWhat happens
2xx after the messageDelivered.
4xx, no reachable host, DNS failureDeferred and retried with backoff, until queue.max_lifetime.
5xxBounced: 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 serverBounced 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

LevelWhat happens
noneNever use TLS.
mayOpportunistic 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.
encryptTLS is required, but the certificate is not checked. Protects against passive eavesdropping only.
verifyTLS 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.
daneDANE (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 testing mode 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 REQUIRETLS itself, 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).

KeyTypeDefaultDescription
nameserversarray of IP addressessystem'sNameservers to ask, such as ["127.0.0.1"] for a local Unbound.
portinteger53Their port.
timeoutduration5sTime per query.
dnssecauto | on | offautoWhether 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:

ModeWhat happens
enforceOnly 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.
testingDelivered as usual; problems are only reported with TLS-RPT.
noneNo 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).

KeyTypeDefaultDescription
enabledbooleantrueApply the MTA-STS policies of recipient domains.
fetch_timeoutduration60sTime to fetch a policy.
servebooleanfalseServe this server's policy over HTTPS, for every mta-sts.<domain> that is asked for. Needs a TLS certificate.
address, portIP address, integer0.0.0.0, 443Where to serve it.
modeenforce | testing | nonetestingThe mode of the policy served. Start with testing, read the TLS-RPT reports, then move to enforce.
mxarray of host names[server.hostname]The MX hosts of the policy served. *.example.com matches one label.
max_ageduration7dHow 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.

KeyTypeDefaultDescription
reportsbooleanfalseRecord outcomes and send reports.
report_intervalduration1dHow often reports are sent.
report_orgstringserver.hostnameThe organization named in reports.
report_fromemail addresspostmaster@<server.hostname>The sender of reports.
contact_infostringreport_fromHow 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]"]
KeyTypeDefaultDescription
greet_delaydurationunsetHold 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_weightintegerthresholdAdded for talking before the greeting. By default, enough to refuse the client on its own.
thresholdinteger1The score at which a client is refused.
allow_thresholdinteger-1The score at or below which a client is allow-listed.
lookup_timeoutduration10sHow long to wait for the DNS lists. Lists that do not answer in time add nothing.
cache_timeduration1dHow long a client that passed is remembered, in memory.
dnsblarray of tables[]DNSBL and DNSWL lists of client addresses (RFC 5782).
rhsblarray of tables[]Lists of domains (RFC 5782 §2.3).

Each list has:

KeyTypeDefaultDescription
zonedomainrequiredThe list's DNS zone.
weightinteger1Added to the score when the client or domain is listed. Negative for allow lists.
codesarray 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.
checkarray of helo, senderbothRHSBL 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):

CheckEffect
require_reverse_hostname550 5.7.25 unless the client address has a reverse DNS (PTR) name.
require_fcrdns550 5.7.25 unless that name also resolves back to the client address. Stricter.
reject_forged_helo550 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_helo550 5.7.1 unless the EHLO name has address or MX records.
require_matching_helo550 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.

KeyTypeDefaultDescription
client_connectionsrateunsetNew connections. Extra ones get 421 4.7.0 and are closed.
client_messagesrateunsetMessages (MAIL commands). Extra ones get 450 4.7.1.
client_recipientsrateunsetAccepted recipients. Extra ones get 450 4.7.1.
user_messagesrateunsetMessages per authenticated user: a sending quota.
user_recipientsrateunsetRecipients 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.

KeyTypeDefaultDescription
enabledbooleanfalseGreylist. Delays the first mail from every new sender by delay or more.
delayduration5mHow long a new triplet is deferred.
retry_windowduration2dHow long a deferred triplet waits for its retry.
max_ageduration35dHow 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].

KeyTypeDefaultDescription
enabledbooleantrueSuspend users.
max_failure_percentinteger50Failed share of recipients, in percent.
min_failuresinteger20Failures needed before a user is suspended.
windowduration1hThe period counted.
suspend_timeduration1hHow 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"
KeyTypeDefaultDescription
namestringaddressThe name listeners use in milters.
addressstringrequiredinet:host:port, inet:port@host (Sendmail style), inet6:port@host, or unix:/path (or local:/path).
default_actiontempfail | accept | rejecttempfailWhat 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_timeoutduration30sTo connect.
command_timeoutduration30sFor each reply to the connection, EHLO, MAIL, and RCPT steps.
content_timeoutduration5mFor 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.

KeyTypeDefaultDescription
timeoutduration100sFor each request.
default_actionstring451 4.3.5 Server configuration problemThe 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 = true

The 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-v2

Clients 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.

KeyTypeDefaultDescription
serverhost[127.0.0.1]:25The listener to send to.
origindomainserver.hostnameThe 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
FileContents
sovite.tomlThe 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.shsovitectl 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.txtWhat 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) and mynetworks; 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, including pipe(8) commands, which become [pipe.NAME] sections); milters with their timeouts and default actions; smtpd_*_restrictions (merged into Sovite's stages, with reject_rbl_client and the like becoming [screen] lists); policy servers, including programs master.cf runs with spawn(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]] per inet smtpd or postscreen service and address of inet_interfaces, with its mode (submission, submissions for smtps or smtpd_tls_wrappermode), and the -o overrides that have listener keys: smtpd_sasl_auth_enable, smtpd_tls_security_level=encrypt, permit_sasl_authenticated, reject lists (require_auth), smtpd_milters, content_filter (an empty one makes the reinjection listener 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's 10-master.conf, such as /run/dovecot/auth-client and /run/dovecot/lmtp, and change auth.dovecot.socket and 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 a sandbox. 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.

KeyTypeDefaultDescription
backenddatabase | file | ldap | dovecotdatabaseWhere users and passwords come from, see below.
mechanismsarray of SCRAM-SHA-256 | PLAIN | LOGIN | OAUTHBEARERby backendSASL 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.
plaintextbooleanfalseAlso offer AUTH on unencrypted connections. Leave it off: with it, passwords cross the network in the clear.
max_failuresinteger10Failed logins from one client address (one /64 for IPv6) within failure_window before it is banned.
failure_windowduration10mSee max_failures.
ban_timeduration1hHow 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_delayduration1sWait before answering a failed login, to slow down guessing. After 3 failures in one session the connection is closed.
sender_checkbooleantrueEnforce sender login maps (below).
senderstable{}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]

KeyTypeDefaultDescription
file.pathabsolute pathunsetUsers 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.

KeyTypeDefaultDescription
ldap.serversarray of host names[]Required. Tried in order.
ldap.portinteger389, or 636 for ldapsServer port.
ldap.securitystarttls | ldaps | nonestarttlsThe server's certificate is verified against the system CAs.
ldap.basestringunsetSearch base, such as ou=people,dc=example,dc=com.
ldap.filterLDAP 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_templatestringunsetBind as this DN directly instead of searching, such as uid=%n,ou=people,dc=example,dc=com.
ldap.bind_dn / ldap.bind_passwordstringunsetAccount for the search. Anonymous if unset.
ldap.timeoutduration10sFor 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.

KeyTypeDefaultDescription
dovecot.socketstringunsetRequired. Path of Dovecot's auth client socket, such as /run/dovecot/auth-client, or host:port.
dovecot.timeoutduration30sHow 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).

KeyTypeDefaultDescription
introspection_urlURLunsetThe provider's introspection endpoint.
client_id / client_secretstringunsetCredentials for the endpoint.
username_claimstringusernameClaim of the introspection response that holds the login, such as email or preferred_username.
required_scopestringunsetA scope the token must have.

[submission]

KeyTypeDefaultDescription
strip_headersarray of strings["Return-Path"]Header fields removed from messages of authenticated clients. Add "X-Originating-IP" and similar to hide client details.
future_releasebooleantrueOffer FUTURERELEASE (RFC 4865) on submission listeners: authenticated clients may hold messages for later, see ESMTP extensions.
max_future_releaseduration7dHow 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:

  1. SPF (RFC 7208) when the client sends MAIL: may the client's address send mail for the MAIL FROM domain, and for the HELO name? At most 10 DNS-querying terms and 2 void lookups are followed, as the RFC says.
  2. 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.
  3. ARC (RFC 8617): the chain of seals forwarders and mailing lists added.
  4. 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 the From: 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.com

Mail 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]

KeyTypeDefaultDescription
verifybooleantrueCheck SPF for mail from outside.
helobooleantrueAlso check the HELO name. For the null sender <>, the HELO name is always the identity checked.
reject_failbooleanfalseRefuse MAIL with 550 5.7.23 when SPF fails. Off by default: forwarding breaks SPF, and DMARC makes the better decision with DKIM too.
timeoutduration20sTime for the whole check. Longer checks give temperror.

[dkim]

KeyTypeDefaultDescription
verifybooleantrueCheck the DKIM signatures of mail from outside.
signbooleantrueSign mail from users with the keys below.
headersarray of stringssee belowThe 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.
expirationdurationunsetSignatures 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
KeyTypeDefaultDescription
domaindomainrequiredThe signing domain (d=).
selectorselectorrequiredThe selector (s=): the record is published at <selector>._domainkey.<domain>.
fileabsolute pathrequiredThe 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.
signbooleantrueSign 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]

KeyTypeDefaultDescription
verifybooleantrueCheck the ARC chain of mail from outside.
sealbooleanfalseARC 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, selectordomain, selectorunsetThe [[dkim.key]] to seal with. Required with seal.
trusted_sealersarray 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]

KeyTypeDefaultDescription
verifybooleantrueEvaluate DMARC for mail from outside.
policyreport | enforcereportWith 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).
reportsbooleanfalseSend 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_intervalduration1dHow often reports are sent.
report_orgstringserver.hostnameThe organization named in reports.
report_fromemail addresspostmaster@<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.

KeyTypeDefaultDescription
enabledbooleanfalseRewrite senders when forwarding.
domaindomainserver.hostnameThe domain of SRS addresses. It must be a local domain of this server, with MX records pointing here.
secretsarray 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_ageinteger21Days 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.

KeyTypeDefaultDescription
double_bounce_recipientemail addressunsetWhere 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_limitsize50000The largest message a failure notification returns whole, for RET=FULL. Larger ones get their header fields returned.

[log]

KeyTypeDefaultDescription
leveldebug | info | notice | warning | errorinfoMinimum level written.
formattext | jsontexttext writes classic log lines; json writes one JSON object per line. See Logging.
directoryabsolute pathunsetWrite 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_namefile name patternsovite.{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_formatstrftime format%Y-%m-%dFormat of {date} (Calendar.strftime/2). Must not produce a /.
max_sizesize100MStart 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.
rotationnever | hourly | daily | weekly | monthlydailyAlso start a new file when the hour, day, ISO week, or month changes.
max_filesinteger14Number of log files to keep, including the current one. Older files matching file_name are deleted after each rotation. 0 keeps all.
symlinkfile nameunsetName of a symlink in directory that always points to the current file, for tail -F.
syslogbooleanfalseAlso send logs to a syslog daemon, instead of to standard output. With directory set, logs go to both.
syslog_addresspath or host:port/dev/logA 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_facilitymail | daemon | user | local0 … local7mailThe syslog facility.
syslog_formatrfc3164 | rfc5424rfc3164The 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.

KeyTypeDefaultDescription
enabledbooleantrueRun the web server. Without it, sovitectl queue can only read the queue, and config reload, config diff, and status do not work.
addressIP address127.0.0.1Address to listen on.
portinteger9025TCP port.
token_fileabsolute path/var/lib/sovite/admin.tokenThe 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_networksarray of networks["127.0.0.0/8", "::1"]Clients outside these networks get 403, token or not.
tlsbooleanfalseServe HTTPS with the server's certificates ([tls]).
public_metricsbooleanfalseServe /metrics without the token, to clients in allowed_networks.
web_rootabsolute pathunsetThe web UI's files. Unset: priv/web in the release, the build of lib/core/sovite_web. They need no token.

[metrics]

KeyTypeDefaultDescription
enabledbooleantrueServe 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.

KeyTypeDefaultDescription
enabledbooleanfalseSend traces.
endpointURLhttp://127.0.0.1:4318/v1/tracesThe collector's OTLP/HTTP traces URL. https URLs are verified against the system CAs.
headerstable of strings{}Extra request headers, such as { authorization = "Bearer ..." }.
sample_rationumber, 0 to 11.0The share of traces sent. All the deliveries of one message are sampled together.
service_namestringsoviteThe 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"
KeyTypeDefaultDescription
enabledbooleanfalseRun as a cluster node. Needs node_name, address, cert_file, key_file, and ca_file.
node_namename@hostunsetThis node's name, unique in the cluster. The host part is usually the node's address.
addressIP addressunsetThe address to accept other nodes on. They are told it, so it must be one they can reach: not 0.0.0.0.
portinteger4370The port to accept other nodes on.
seedsarray 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_fileabsolute pathunsetThis node's certificate (PEM), signed by the cluster CA.
key_fileabsolute pathunsetIts private key.
ca_fileabsolute pathunsetThe 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_intervalduration10sHow often to try the seeds that are not connected.
sync_intervalduration1sHow often nodes send each other what their rate limits counted, failed logins, suspended accounts, and greylist entries.
shared_configabsolute pathunsetThe cluster's shared configuration, kept up to date by the cluster: see below.
queue_replicasinteger, 0 to 21Other 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_afterduration10mHow long a node must stay down before the others deliver its messages from their copies.
destination_affinitybooleanfalseSend 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
KeyTypeDefaultDescription
namenamerequiredLetters, digits, _, and -. Kept with each queued message.
domainsarray of domains[]The tenant's domains, with their subdomains. A domain belongs to one tenant only.
usersarray of strings[]Logins of the tenant, besides those in its domains (user@acme.example).
ip_poolnameunsetThe IP pool its mail goes out from, instead of delivery.ip_pool.
messagesrateunsetMessages per period for the whole tenant, such as "10000/1d": 450 4.7.1 over it.
recipientsrateunsetRecipients per period for the whole tenant.
max_message_sizesizeunsetThe largest message: 552 5.3.4 over it, at MAIL (with SIZE) or after the data.
max_recipientsintegerunsetRecipients 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
KeyTypeDefaultDescription
namenamerequiredThe pool's name.
overflownameunsetThe 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.
addressarray of tablesrequiredThe pool's addresses, below.

Each [[ip_pool.address]]:

KeyTypeDefaultDescription
addressIP addressrequiredA local address of the node that has it.
helohost nameserver.hostnameThe EHLO name of connections from it: the name its reverse DNS gives.
nodenode namethis nodeIn 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_startdateunsetThe first day of the warm-up schedule. The address is not used before it.
warmuparray of integers[]Messages per day, from warmup_start on: the first entry for that day, the second for the next, and so on.
daily_limitintegerunsetMessages 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 in config/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.