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
ratelimitmodule (renderedlocal.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" exceededPostfix logs it as a
milter-reject(usually with a queue id, at end of message), and the delivery log classifies itthrottled— 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-daemonrecipients are exempt so bounces and abuse reports always get through. -
Edit under Settings (validated and applied like any other setting, with history and rollback);
0disables a limit. Postfix’ssmtpd_client_connection_rate_limitis separate and set in the same Sending limits section — and it counts connections per client IP per hour, not per minute. The renderedmain.cfsetsanvil_rate_time_unit = 3600s, which is what the limit is measured against; the shipped default of60therefore 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 see421refusals 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.startedAtis 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
perUserMessagesPerHourfor that mailbox, a domain override replacesperDomainMessagesPerHourfor 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;0means 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’sautoBlock, 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_domainandwarmupappear 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 amilter-rejectfrom the same milter. Overridingmessageinlocal.d/ratelimit.confmakes throttled mail count against the mailbox in compromise detection. Since the buckets became Lua handlers the names are the keyword names inrate_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_limitmapping (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 /messagesis refused with an error (429 rate_limitedfrom 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.