MtaOutboundThrottle IF/THEN match conditions appear to be ignored

Issue Description

In Stalwart Server v0.16.21, conditional IF/THEN branches in the match expression of an MtaOutboundThrottle appear to be ignored.

I have an outbound rate limiter configured with:

Description: sender: 300 messages per day
Key: Sender
Rate: 300 messages per 1 day

I need to exempt a high-volume journal mailbox from this sender-based throttle.

The match condition is configured as:

IF: contains_ignore_case(recipients, ‘[email protected]’)
THEN: false
ELSE: true

The configuration is successfully saved and is visible through stalwart-cli. I also performed a full Stalwart server restart after making the change.

Despite this, messages whose only recipient is [email protected] continue to be limited by this MtaOutboundThrottle.

The queued message clearly contains:

to = [“[email protected]”]

but the throttle is still applied.

The behavior suggests that the conditional IF/THEN portion of the MtaOutboundThrottle match expression is not being evaluated and that only the default/ELSE expression may be used.

Expected Behavior

The complete match expression configured on an MtaOutboundThrottle should be evaluated.

For this configuration:

IF contains_ignore_case(recipients, ‘[email protected]’)
THEN false
ELSE true

a message with [email protected] in the recipients array should evaluate the throttle match expression to false.

Therefore, the sender rate limiter should not apply to that message.

Messages to other recipients should continue to match the ELSE true branch and remain subject to the configured sender rate limit.

Actual Behavior

Messages addressed only to [email protected] continue to be subject to the MtaOutboundThrottle.

The recipient is present in the queued message as:

to = [“[email protected]”]

but Stalwart still reports:

queue.rate-limit-exceeded
delivery.rate-limit-exceeded

for the same MtaOutboundThrottle.

This continues after:

  1. Saving the modified throttle.
  2. Verifying the saved object using stalwart-cli.
  3. Fully restarting the Stalwart server.

The CLI shows the expected condition:

Match condition:
  Expression
    Conditions:
      #  Condition                                                   Result
      0  contains_ignore_case(recipients, '[email protected]')     false
    Default Value: true

However, at delivery time the throttle still matches the message.

Reproduction Steps

  1. Run Stalwart Server v0.16.21.

  2. Create an outbound rate limiter with:

    • Key: Sender
    • Rate limit: 300 messages per 1 day
    • Match condition: true
  3. Cause a sender to reach the configured rate limit.

    In my case, journal messages have an empty envelope sender:

    from = “<>”

    and therefore share the same sender rate-limit bucket.

  4. Modify the outbound rate limiter match condition to:

    IF: contains_ignore_case(recipients, ‘[email protected]’)
    THEN: false
    ELSE: true

  5. Save the throttle.

  6. Verify the stored configuration:

    stalwart-cli get MtaOutboundThrottle

    The output shows:

   Outbound Rate Limiter
     Description: sender: 300 messages per day
     Keys:        Sender
     Rate limit:
       Rate
         Count:  300
         Period: 1 d
     Match condition:
       Expression
         Conditions:
           #  Condition                                                   Result
           0  contains_ignore_case(recipients, '[email protected]')     false
         Default Value: true
     Enabled: Yes
  1. Restart the Stalwart server to ensure the updated configuration has been loaded.

  2. Retry or enqueue a message where:

    from = “<>”
    to = [“[email protected]”]

  3. Observe that the message is still rejected/deferred by the same MtaOutboundThrottle with:

    queue.rate-limit-exceeded
    delivery.rate-limit-exceeded

even though the match expression should evaluate to false for that recipient.

Relevant Log Output

2026-09-09T23:30:25Z INFO Rate limit exceeded (queue.rate-limit-exceeded) queueId = 328594213249564160, queueName = "local", from = "<>", to = ["[email protected]"], size = 13021, total = 1, id = "MtaOutboundThrottle with id itupxlq1abqa", limit = [300, 86400000ms]

2026-09-09T23:30:25Z WARN Rate limit exceeded (delivery.rate-limit-exceeded) queueId = 328594213249564160, queueName = "local", from = "<>", to = ["[email protected]"], size = 13021, total = 1, id = "MtaOutboundThrottle with id itupxlq1abqa", nextRetry = 2026-09-10T00:00:00Z

2026-09-09T23:35:34Z WARN Rate limit exceeded (delivery.rate-limit-exceeded) queueId = 328593976512555520, queueName = "local", from = "<>", to = ["[email protected]"], size = 13010, total = 1, id = "MtaOutboundThrottle with id itupxlq1abqa", nextRetry = 2026-09-10T00:00:00Z

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

PostgreSQL

Blob Storage

S3-compatible

Search Engine

PostgreSQL

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 looks like a genuine bug in the rate limiter configuration loader, not a mistake in your setup. Your diagnosis is correct.

When Stalwart parses each MtaOutboundThrottle object at startup, it compiles the match field into an IfBlock (which has both the conditional if_then branches and the default/else expression), but it only keeps the .default part when building the actual rate-limiter used at delivery time:

let limiter = QueueRateLimiter {
    expr: bp.compile_expr(obj.id, &obj.object.ctx_match_()).default,
    ...

(crates/common/src/config/smtp/queue.rs, in QueueRateLimiters::parse_outbound). The if_then conditions (your contains_ignore_case(recipients, '[email protected]') -> false) are discarded, and only the else value (true) is stored and evaluated against every message. That is exactly the behavior you observed: the IF/THEN branch is silently ignored and only the ELSE default applies, regardless of recipients.

This affects MtaOutboundThrottle (and likely MtaQueueQuota, which uses the same pattern in the same file) whenever the match condition uses conditional IF/THEN branches rather than a single unconditional expression in else.

As a workaround until this is fixed, you cannot rely on IF/THEN branching in these throttle match expressions; you would need a single expression that itself embeds the exemption logic (e.g. using boolean/short-circuit functions rather than the IF/THEN block), since only the else_ field is honored.

A human maintainer will need to confirm and patch this, since it is a code-level defect rather than a documentation or config issue.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.22 (yet to be released). Stalwart only uses the else branch as the if/then blocks and be combined in a single boolean expression. However, since the UI allows if/blocks, Stalwart will now accept them too.