Skip to content

Outbound rate limits

Purpose. A compromised mailbox must not be able to burn the server’s IP reputation in an hour. Every authenticated sender and every sender domain gets an hourly budget; above it, submissions are deferred, not lost.

Behaviour.

  • Rspamd’s ratelimit module (rendered local.d/ratelimit.conf, buckets in Redis) counts envelope recipients per authenticated user (limits.perUserMessagesPerHour, default 500) and per sender domain (limits.perDomainMessagesPerHour, default 2000). A sender over budget gets a soft reject on submission; well-behaved clients retry later, a spam script stalls. The reply is rspamd’s own, naming the bucket that filled:

    451 4.7.1 Ratelimit "user" exceeded

    Postfix logs it as a milter-reject (usually with a queue id, at end of message), and the delivery log classifies it throttled — see Which refusals count in Compromised-mailbox containment. Buckets refill continuously (a 500/h limit admits ~8 recipients a minute sustained, with a burst of 500). A message counts once per envelope recipient: one message to 20 addresses uses 20 of the hour’s allowance in each bucket.

  • Only authenticated mail is counted, in all three buckets. Inbound mail on port 25 is never counted or throttled here, whatever envelope sender it claims. That includes mail that claims one of your own domains, so nobody outside can use up a domain’s budget by forging its address. Mail the server only forwards (an alias to an outside address, a Sieve redirect) is not authenticated either, so it is not counted. The product’s own sending (webmail, the messages API) signs in, so it is counted. postmaster/mailer-daemon recipients are exempt so bounces and abuse reports always get through.

  • Edit under Settings (validated and applied like any other setting, with history and rollback); 0 disables a limit. Postfix’s smtpd_client_connection_rate_limit is separate and set in the same Sending limits section — and it counts connections per client IP per hour, not per minute. The rendered main.cf sets anvil_rate_time_unit = 3600s, which is what the limit is measured against; the shipped default of 60 therefore admits 60 connections an hour from one address, roughly one a minute, not 60 a minute. Getting the unit wrong here is a factor of sixty in the direction that hurts: a value chosen as a generous per-minute allowance is a tight per-hour one, and a busy office behind a single NAT address will see 421 refusals that look like an outage.

  • IP warm-up (Settings → Reputation protection → IP warm-up): for a new IP, one server-wide outbound cap starts at 50 recipients/hour on day 1 and grows 25 % per day (63, 78, 98, 122, … ≈ 1 800 on day 17) until it reaches the target (default 2000/h), then disappears. The API re-renders the cap hourly when it changes (configuration versions tagged warm-up:); enabling it in Settings → Reputation protection records the start date (through the API, warmup.startedAt is optional, and without it no cap is rendered). Receivers (Gmail/Microsoft) judge new IPs by volume ramps — this keeps the ramp gentle without babysitting.

Configuration. ServerConfig.limits.{perUserMessagesPerHour,perDomainMessagesPerHour,smtpdClientConnectionRateLimit}, ServerConfig.warmup.{enabled,startedAt,targetPerHour}, ServerConfig.burstAlert.{enabled,windowMinutes,minEvents}. Overrides live in the rate_limit_override table, not in the configuration: they are per-tenant state, so they reach rspamd as an apply-time render extra and a configuration rollback cannot revert an unrelated domain’s allowance.

How the limits are enforced. The per-user and per-domain buckets are rspamd custom_keywords handlers in rate_limits.lua rather than declarative rates { } entries, because a declarative bucket can only make a sender stricter: rspamd refuses when any bucket is over, so a looser per-mailbox bucket would still be refused by the server-wide one, and raising a newsletter mailbox’s allowance is the common request. The settings module is not an option either — ratelimit reads only its own configuration and never consults per-task settings, so the per-entity apply { } blocks that per-domain spam scores use do nothing here. The handler therefore owns both the default and the override, and reads the overrides from rate_limit_overrides.map. Saving an override is a configuration apply, and the apply reloads rspamd, which is what loads the new map: the file is root-owned, so the main process (root) reads it at start and on reload, while a worker re-reading the changed file on its own is refused. So an override takes effect a moment after it is saved, but editing the file by hand does nothing until rspamd is reloaded. The warm-up cap stays declarative and still applies on top of any per-mailbox grant — a young IP has to stay quiet whatever an individual mailbox was given.

Limits & edge cases.

  • Overrides are set per mailbox (Mailbox → Limits → Sending) and per domain (Domain → Sending limit). Each replaces the same-named server setting and nothing else: a mailbox override replaces perUserMessagesPerHour for that mailbox, a domain override replaces perDomainMessagesPerHour for that domain. A domain override is that domain’s combined ceiling and does not change what an individual mailbox in it may send — the two are separate buckets and both have to pass. Clearing the field removes the override; 0 means unlimited, as it does server-wide.

  • Burst alerts. A sender that is repeatedly held back raises a warning alert on the dashboard naming the sender and which bucket refused, with a link to the throttled messages in the delivery log. The worker checks every 10 minutes; the default is 10 refusals in a rolling 60 minutes (ServerConfig.burstAlert.{enabled,windowMinutes,minEvents}), and the alert resolves itself when the bursting stops. It is on by default — unlike containment’s autoBlock, it changes nothing except what an operator can see. The source is the delivery log, not rspamd: rspamd keeps its counters in opaque Redis keys that name neither the user nor the sender, so they cannot answer “which mailbox”, and they are lost on a Redis restart. See Compromised-mailbox containment for the alert that does act.

  • The bucket names are part of a contract. user, sender_domain and warmup appear verbatim in rspamd’s refusal text, and that text is the only thing separating “we throttled this” from “our spam filter refused this” — both reach Postfix as a milter-reject from the same milter. Overriding message in local.d/ratelimit.conf makes throttled mail count against the mailbox in compromise detection. Since the buckets became Lua handlers the names are the keyword names in rate_limits.lua, and renaming one has the same effect.

  • A domain override does not cascade to its mailboxes, and there is no per-domain default for the mailboxes inside it. An administrator who wants every mailbox in a domain raised sets each one.

  • Redis is not persisted (--save ''), so counters reset when Redis restarts.

  • The previous Postfix smtpd_client_message_rate_limit mapping (per client IP per minute, mislabelled as per user per hour) was removed in favour of the rspamd buckets.

  • The warm-up cap is a soft reject like the other buckets: a desktop client keeps the message and retries; a webmail send or a POST /messages is refused with an error (429 rate_limited from the API) and has to be sent again. A message scheduled with Send later does not: it is tried again every 5 minutes, six attempts in all, before it is marked failed (Compose, send and drafts). Heavy senders will notice deferrals during the first days — that is the point.

  • The warm-up cap counts what your users and the product send. Mail the server only relays — forwards to outside addresses, Sieve redirects, auto-replies, bounces — is not counted, so a server that forwards heavily sends more than the cap during warm-up.

  • Throttling between two local mailboxes is logged as internal mail, so it never raises a burst alert, which reads outbound refusals only.