Greylisting never clears on retry — every retry is greylisted again

Issue Description

I’m currently evaluating Stalwart with the 30-day Enterprise trial, running v0.16.16 (Docker image stalwartlabs/stalwart:v0.16.16), and I’m seeing unexpected greylisting behavior.

Configuration: greylistFor = 120000 (2 minutes), set via the admin panel (Spam Filter settings).

Expected behavior: after a sender is greylisted once, a retry from the same IP/sender/recipient triplet should be accepted once the greylist delay has elapsed.

Observed behavior: every retry is greylisted again, indefinitely. No sender has ever passed greylisting on retry, even when retrying 15–25 minutes later (well past the 2-minute window) with the same source IP and same envelope sender/recipient.

Questions:

What is the exact semantics of greylistFor? Is it the minimum delay before a retry is accepted, or the TTL of the greylist entry?

What is the greylist key (IP, IP+sender+recipient, other)? Is there a known issue where the entry is never converted to “accepted” on retry?

Side question: I’ve noticed that several configuration changes made via the admin panel/API are persisted but not picked up by the running process — they only take effect after a server restart. Observed for: outbound route strategy, IP blocklist removal, the Enterprise license activation, and the greylist setting itself. Is hot-reload of settings expected to work in v0.16.16?

Actual Behavior

Log evidence (timestamps in UTC):

2026-08-06T13:15:59Z INFO RCPT TO greylisted remoteIp = 198.2.177.6, to = “[email protected]” (Mandrill, 1st attempt)

2026-08-06T13:30:59Z INFO RCPT TO greylisted remoteIp = 198.2.177.6, to = “[email protected]” (retry +15min)

2026-08-06T13:35:55Z INFO RCPT TO greylisted remoteIp = 198.2.177.6, to = “[email protected]” (retry, same MAIL FROM as 1st attempt)

2026-08-06T13:38:08Z INFO RCPT TO greylisted remoteIp = 198.2.177.6, to = “[email protected]” (retry +22min)

2026-08-06T13:09:21Z INFO RCPT TO greylisted remoteIp = 135.181.195.209, to = “[email protected]

2026-08-06T13:11:24Z / 13:15:38Z / 13:17:42Z / 13:22:45Z / 13:32:48Z — same IP, same recipient, all greylisted again

2026-08-06T13:18:06Z / 13:24:15Z / 13:36:02Z — 54.240.8.13 (Amazon SES), same recipient, all greylisted again

This is currently blocking all legitimate mail from new senders (Mandrill, Amazon SES, etc.), so I had to disable greylisting.

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This confirms most settings require explicit “reload” actions rather than automatic hot-reload. Let me check the enterprise.md and reload docs a bit more, and check the “Action” object doc for reloadSettings behavior.

Now I have enough to answer both parts confidently.

I now have what I need to answer both the greylist logic question and the reload question.

Greylist semantics and why it never clears

greylistFor is the duration a greylist entry (a triplet of sender IP-address-derived key: actually the key is the sender envelope‑from + envelope‑to, not the IP) stays in the store; it is a TTL, not a “minimum wait before acceptance.” The logic is: on RCPT TO, the server builds a key from mail_from + rcpt_to, checks if that key exists in the in‑memory/key‑value store. If it doesn’t exist, the server sets the key (with the greylistFor expiry) and rejects with 452 (greylisted). If the key does exist, the message is allowed through — it does not reject again. So a retry should only be re-greylisted if the original entry has already expired (i.e., the retry happens after greylistFor has elapsed) or if the key was never actually persisted (e.g., the key-value store isn’t returning Ok(true) on key_exists for some reason — for instance if the backing in‑memory store is misconfigured, or entries are evicted/not durable across restarts).

Given your logs, retries 15–22 minutes after the first attempt with the same MAIL FROM/RCPT TO/IP are being greylisted again even though greylistFor is 2 minutes (so the original entry should long since have both been created and still exist, since 2 minutes < 15 minutes only means it will have expired, which is expected — wait: if greylistFor = 2 minutes, an entry created at 13:15:59 expires at 13:17:59, so a retry at 13:30:59 arrives after the entry already expired, meaning key_exists returns false again, a new record is created, and the sender is greylisted anew). That matches the “TTL” semantics: greylistFor is not a minimum delay before acceptance, it’s how long the “known” record is remembered. If the retrying MTA’s retry interval is longer than greylistFor, the triplet will look “new” again every time and be greylisted indefinitely. This is inherent to the design: greylistFor needs to be set longer than the expected retry interval of legitimate MTAs (many wait 15–30+ minutes for the first retry), not shorter. With greylistFor = 2 minutes, only senders that retry within that 2-minute window will ever pass; anything retrying later (which is normal MTA behavior) will always re-trigger. Setting greylistFor to something like 30m or longer is what’s documented as the intended pattern (the docs example uses 30d).

On the hot-reload side question

Configuration changes are not automatically hot-reloaded; they require you to trigger the appropriate Action:

  • ReloadSettings — rereads server settings from the data store without a restart (this is what a greylistFor/outbound route strategy change needs).
  • ReloadTlsCertificates, ReloadLookupStores, ReloadBlockedIps — for those respective areas.

For an Enterprise license activation specifically, after saving the license key you also need to run a settings reload from the CLI, and log out/back in before Enterprise features show up in the WebUI session. None of these require a full process restart if the corresponding Action is invoked.

The setting means close to the opposite of what you’ve assumed, our documentation is not stating this clearly.

greylistFor is the lifetime of the “I’ve seen this pair before” entry, not a minimum delay before a retry is accepted. While that entry exists the retry passes; once it expires the pair is greylisted again from scratch. So at 2 minutes, a sender that retries after 15 minutes finds no entry and gets greylisted afresh, every single time, which is exactly the pattern in your log. Mandrill and SES back off well past 2 minutes, so they could never have passed. Set it on the order of days or weeks; our own docs use 30d as the example, and that’s the right scale.

Your arithmetic was fine, by the way: the unit is milliseconds, so 120000 really was 2 minutes.

On the key, it’s the envelope sender plus the envelope recipient, with no IP in it, which is deliberate so that ESP pools rotating source addresses aren’t penalised. Our greylisting docs claim it’s a triplet including the sender IP, and that the entry stays “greylisted” for the duration. Both are wrong and I’ll get them fixed. One design nit while I’m at it: the entry isn’t refreshed when a retry passes, so it lapses on the original TTL regardless, which is another reason to set it long.

On your side question, you’re correct, and it isn’t per-setting flakiness, it’s systematic: in 0.16.16 nothing in the settings write path rebuilds the running config, so everything you save is persisted but not live. You don’t need a restart though. The ReloadSettings action covers all of it, with one exception: blocklist removals need ReloadBlockedIps, because insertions are propagated incrementally while deletions aren’t. Check the log after triggering a reload, since if any settings object fails validation the reload silently does nothing and only logs the errors.