Skip to content

Tuning mail throughput

Mail throughput is the rate at which a server moves mail, and it is limited on two sides. On the outbound side, the limit is concurrency: the number of deliveries a node runs at the same time decides how fast the queue drains. On the inbound side, the limit is the cost of each message. Every message passes through the spam filter, DNS checks and, when enabled, external classifiers before it is stored, and each of them adds time per message.

This page covers both sides. The memory budget, which limits how many messages are processed at the same time, is described separately.

Outbound messages wait in virtual queues until a delivery thread picks them up. Each MtaVirtualQueue (found in the WebUI under Settings › MTA › Outbound › Virtual Queues) has its own number of threads, threadsPerNode, which limits the deliveries the queue runs in parallel on each node. A new installation is created with four queues: local with 25 threads, remote with 50, dsn with 16 and report with 8. A queue created later starts with 25.

A queue that has too few threads shows back pressure. When a queue is at its limit while messages are waiting, the queue.back-pressure counter increases, and a queue.back-pressure event at the warn level names the queue and its limit. The event is emitted at most once a minute per queue. Two other signals show the state of delivery: the delivery.active-connections gauge shows the delivery attempts in progress, and queue.count shows the messages in the queue.

The remedy is to raise threadsPerNode for the queue that reports back pressure. The change takes effect after a settings reload, and the following command raises the limit of a queue to 100 threads:

Terminal window
stalwart-cli update MtaVirtualQueue <id> --field threadsPerNode=100

More threads do not help when the threads are busy waiting. A delivery holds its thread until the remote host answers or a timeout expires. The connection strategy gives each step of the transaction its own timeout, from the connection and greeting through EHLO, MAIL FROM and RCPT TO to the message data. Their defaults are measured in minutes (10 minutes for the message data), so a few unresponsive hosts can occupy many threads. Shorter timeouts free the threads sooner, at the risk of abandoning deliveries to slow but working hosts.

Delivery can also be slowed on purpose, by outbound rate limits and concurrency limits. When one of them defers a delivery, the server emits queue.rate-limit-exceeded or queue.concurrency-limit-exceeded. These events point to a configured limit rather than a shortage of threads, so raising threadsPerNode does not help with them.

Both sides of mail flow depend on DNS. Outbound delivery looks up the recipient’s mail servers, and the inbound checks query DNS too: SPF, DKIM, DMARC, DNSBL, MTA-STS and DANE. A slow resolver therefore slows both.

The resolver settings are on the DnsResolver object (found in the WebUI under Settings › Network › DNS › DNS Resolver). maxConcurrentRequests is the number of name servers queried in parallel for each lookup, attempts the number of retries, and timeout the time to wait for an answer. The dns.lookup-time histogram shows the lookup latency. Resolver changes take effect after a settings reload.

Answers are cached only in the DNS caches, so the size of those caches also decides how many lookups reach the name servers.

The spam filter analyses the text of each message, and the amount of text it reads drives its CPU cost. Three settings on SpamSettings (found in the WebUI under Settings › Spam Filter › General) bound that amount:

  • maxBodyText limits the text read from each body part.
  • maxAttachmentText limits the text read from each other text part, such as a text attachment.
  • maxMessageText limits the text read per message across all parts.

Lower values reduce the CPU time per message, at the cost of examining less of it.

The filter also follows URL redirects in messages to find their final destination, which costs network requests rather than CPU. By default it sends at most 10 such requests per message, not counting redirects already cached, and waits up to 5 seconds for each. A message with many links to slow redirect services can therefore take several seconds longer to process. The limits are maxRedirectUrls and redirectTimeout. Resolved redirects are kept in the spamLookup cache for redirectCacheTtl, so a redirect seen again within that time costs no request.

Pyzor is disabled by default. When it is enabled, it adds a network round trip to the Pyzor server for each message, bounded by its timeout.

These settings take effect after a settings reload. The time to store each accepted message is reported by the message-ingest.time histogram.

AI spam classification sends a message to an AI model and waits for its verdict, so its cost is the time spent waiting. On each AiModel (found in the WebUI under Settings › AI), three settings bound it. maxConcurrentRequests limits the requests sent at the same time, timeout the wait for a response, and the optional rateLimit the requests per period.

The most effective way to bound the cost is to choose which messages are sent to the model at all, as described on the AI classification page. The ai.request-time histogram shows the time spent waiting for the model. The ai.breaker-open counter and event report a model that failed repeatedly and is skipped until its cooldown ends.

An SMTP session holds a connection for as long as it lasts, and a slow or idle client can hold one much longer than it needs. The MtaInboundSession settings (found in the WebUI under Settings › MTA › Inbound › Session) bound what one session can hold:

These settings are expressions, so they can differ by listener or remote address. Lower values release connections held by slow or idle clients sooner. The effect of the memory budget on message intake is described in Sizing memory.