Skip to content

Rate limits

Receiving servers limit how much mail they accept from one sender in a period of time. A server that exceeds the limit receives temporary errors, and one that exceeds it repeatedly ends up with a lower reputation. A sending server therefore does better to pace its own deliveries than to run into the receivers’ limits. Outbound throttles do this pacing: they cap the rate at which Stalwart delivers, so that it stays within the limits that receivers set.

A throttle can count deliveries per destination host, per remote IP address, per recipient domain, per local IP address or per sender. A delivery that would exceed a throttle is postponed, not refused. Throttling therefore delays mail without losing it.

Each throttle is an MtaOutboundThrottle object (found in the WebUI under Settings › MTA › Rates & Quotas › Outbound Rate Limits). Inbound sessions have separate throttles of their own (Inbound rate limits).

A throttle has two required fields. description names it, and rate sets the number of deliveries allowed (count) per period (period, in milliseconds).

The key decides what is counted separately. The available keys are mx (the MX host name), remoteIp, localIp, rcptDomain, sender and senderDomain, and each distinct value of the key gets its own counter. With no key, one counter covers all deliveries. match is an expression over the host variables that restricts the throttle to some deliveries. enable decides whether the throttle is active.

The keys also decide when the throttle is checked:

  • A throttle keyed on sender or senderDomain, or with no key, is checked once for each message, before the message is routed. Its match can therefore use only the variables that describe the message, such as sender, sender_domain, source, priority and size. Host variables such as mx are empty at that point.
  • A throttle keyed on rcptDomain is checked once for each recipient domain of the message.
  • A throttle keyed on mx, remoteIp or localIp, or whose match uses those variables, is checked before each connection to a remote host.

The local address of the connection is chosen before that last check. A throttle keyed on localIp therefore keeps a separate counter for each local address.

The following throttle delivers at most 25 messages per hour to each recipient domain:

{
"description": "Per recipient domain",
"key": {"rcptDomain": true},
"rate": {"count": 25, "period": 3600000}
}

The following throttle limits the deliveries to one large provider’s servers, identified by the MX host name, to 100 per minute for each host:

{
"description": "Provider MX hosts",
"key": {"mx": true},
"match": {"else": "ends_with(mx, '.mail.provider.example')"},
"rate": {"count": 100, "period": 60000}
}

The counters are kept in the in-memory store. In a cluster with a shared in-memory store, all nodes therefore count together, and a limit applies to the cluster as a whole.

When a delivery would exceed a throttle, the recipients affected are put back in the queue with a temporary status, Rate limited. Their next attempt is scheduled for when the throttle allows deliveries again.

A postponement of this kind is not treated as a failure. It does not count as a failed attempt towards an Attempts expiry, and it does not change the recipient’s retry interval. A time-based expiry, however, continues to run, so a recipient that stays rate limited for long enough can still expire.

The postponement is visible in two places. The events delivery.rate-limit-exceeded and queue.rate-limit-exceeded are logged with the throttle’s identifier, limit and period. The strategy expressions see last_error as rate, so they can react to it.

A throttle checked before a connection affects every recipient of that connection. When it is exceeded, every recipient routed to that remote host is postponed. Stalwart does not try another local address or another IP address of the host instead.

Stalwart has no IP warm-up schedule, so a new local address is warmed up with a throttle. The throttle is keyed on localIp, its match selects the address (for example local_ip == '192.0.2.11'), and its count is raised over time. The volume sent to each destination can be limited in the same way, with throttles keyed on mx or rcptDomain.

Warming up an address this way has a side effect. The local address is picked at random for each connection, so reaching the limit of the new address also postpones messages that the other addresses could have sent.