Skip to content

Commit a433576

Browse files
committed
Add configurable SMTP message rate limiting
Lets developers test how their application behaves against a mail server that is throttling them, which is what issue #199 asked for. SmtpServer only ever writes a hardcoded 550 when a mailbox filter returns false, so the filter throws SmtpResponseException instead -- the session loop writes the carried response back verbatim, which is what allows the reply code to be configurable. quit: true keeps the library from appending ", N retry(ies) remaining." to the message. Service-only and off by default, following the AllowedIps precedent: the limiter resolves via ResolveOptional, so the WPF app is untouched. "SmtpServer": { "RateLimit": "500/1h", "RateLimitReplyCode": 451 }
1 parent 6f2aaca commit a433576

17 files changed

Lines changed: 911 additions & 10 deletions

File tree

DOCKERHUB.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ docker run -d \
9595

9696
**Security - SMTP IP Allowlist Configuration:**
9797
- `SmtpServer__AllowedIps` - Comma-separated list of allowed IP addresses or CIDR ranges for SMTP connections
98+
- `SmtpServer__RateLimit` - Message reception rate limit as `<count>/<window>`, e.g. `500/1h` (default: "*" = no limit)
99+
- `SmtpServer__RateLimitReplyCode` - SMTP reply code returned once the rate limit is hit (default: 451)
98100
- `*` - Allow all IPs (default, backward compatible)
99101
- `192.168.1.0/24` - Allow single CIDR range
100102
- `192.168.1.0/24,10.0.0.0/8` - Allow multiple CIDR ranges

docs/service.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,36 @@ Use `http://0.0.0.0:8080` to listen on all interfaces — but read the warning b
6565
!!! warning "Network exposure"
6666
When binding to `0.0.0.0`, `+`, `*`, or a LAN IP, the web UI is reachable from other machines — and Papercut has **no built-in authentication**. Use firewall rules or a reverse proxy with auth in front of it.
6767

68+
**Rate limiting** — reject mail once a quota is hit, so you can test how your application
69+
handles a mail server that is throttling it (`appsettings.json`):
70+
71+
```json
72+
{
73+
"SmtpServer": {
74+
"RateLimit": "500/1h",
75+
"RateLimitReplyCode": 451
76+
}
77+
}
78+
```
79+
80+
`RateLimit` is `<count>/<window>`, where the window is a number followed by `s`, `m`, or `h`
81+
for example `500/1h`, `5/10m`, or `100/30s`. Use `*` (the default) for no limit.
82+
83+
Once the limit is reached, Papercut rejects at `MAIL FROM` until the window resets:
84+
85+
```
86+
451 4.7.1 Message rate limit exceeded (500 per hour)
87+
```
88+
89+
`RateLimitReplyCode` accepts any 4xx or 5xx reply code — 421, 451 (the default), 452 and 550
90+
are the usual choices. A 4xx code tells the sending client the failure is temporary and worth
91+
retrying; a 5xx code tells it the message was permanently refused.
92+
93+
The count is global to the server rather than per-sender or per-IP, and the window is fixed:
94+
it starts when the first message arrives and resets in full once it expires.
95+
96+
Environment variable form (Docker): `SmtpServer__RateLimit` and `SmtpServer__RateLimitReplyCode`.
97+
6898
## API
6999

70100
The web UI is backed by a small HTTP API (`/api/messages`, etc.) you can script against — handy for asserting "an email was sent" in end-to-end tests. Explore the endpoints via your browser's dev tools on the web UI.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Papercut
2+
//
3+
// Copyright © 2008 - 2012 Ken Robertson
4+
// Copyright © 2013 - 2025 Jaben Cargman
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
19+
using SmtpServer;
20+
using SmtpServer.Mail;
21+
using SmtpServer.Storage;
22+
23+
namespace Papercut.Infrastructure.Smtp;
24+
25+
/// <summary>
26+
/// Runs several mailbox filters in order, stopping at the first rejection.
27+
///
28+
/// SmtpServer ships an equivalent internally but does not expose it, and the
29+
/// DelegatingMailboxFilterFactory hook Papercut uses supplies exactly one filter.
30+
/// </summary>
31+
internal sealed class ChainedMailboxFilter(IReadOnlyList<IMailboxFilter> filters) : IMailboxFilter
32+
{
33+
public async Task<bool> CanAcceptFromAsync(
34+
ISessionContext context,
35+
IMailbox from,
36+
int size,
37+
CancellationToken cancellationToken)
38+
{
39+
foreach (var filter in filters)
40+
{
41+
if (!await filter.CanAcceptFromAsync(context, from, size, cancellationToken).ConfigureAwait(false))
42+
{
43+
return false;
44+
}
45+
}
46+
47+
return true;
48+
}
49+
50+
public async Task<bool> CanDeliverToAsync(
51+
ISessionContext context,
52+
IMailbox to,
53+
IMailbox from,
54+
CancellationToken cancellationToken)
55+
{
56+
foreach (var filter in filters)
57+
{
58+
if (!await filter.CanDeliverToAsync(context, to, from, cancellationToken).ConfigureAwait(false))
59+
{
60+
return false;
61+
}
62+
}
63+
64+
return true;
65+
}
66+
}

src/Papercut.Infrastructure.Smtp/PapercutSmtpModule.cs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
using Autofac;
2020

21+
using Papercut.Infrastructure.Smtp.RateLimiting;
22+
2123
using SmtpServer;
2224
using SmtpServer.Authentication;
2325
using SmtpServer.Net;
@@ -53,9 +55,29 @@ protected override void Load(ContainerBuilder builder)
5355
ctx =>
5456
{
5557
var ipAllowedList = ctx.ResolveOptional<IPAllowedList>() ?? IPAllowedList.AllowAll;
56-
var logger = ctx.Resolve<ILogger>().ForContext<IpAllowlistMailboxFilter>();
58+
var logger = ctx.Resolve<ILogger>();
59+
var ipLogger = logger.ForContext<IpAllowlistMailboxFilter>();
60+
61+
// Rate limiting is opt-in and only registered by the service host, so
62+
// hosts that never configure it (the UI) resolve nothing and pay nothing.
63+
var rateLimiter = ctx.ResolveOptional<SmtpRateLimiter>();
64+
var rateLimitLogger = logger.ForContext<RateLimitMailboxFilter>();
65+
5766
return new DelegatingMailboxFilterFactory(
58-
_ => new IpAllowlistMailboxFilter(ipAllowedList, logger));
67+
_ =>
68+
{
69+
var ipFilter = new IpAllowlistMailboxFilter(ipAllowedList, ipLogger);
70+
71+
if (rateLimiter is null || rateLimiter.Limit.IsUnlimited)
72+
{
73+
return ipFilter;
74+
}
75+
76+
// Allowlist first: connections Papercut was never going to
77+
// accept should not consume the caller's quota.
78+
return new ChainedMailboxFilter(
79+
[ipFilter, new RateLimitMailboxFilter(rateLimiter, rateLimitLogger)]);
80+
});
5981
}).As<IMailboxFilterFactory>();
6082

6183
builder.Register<SmtpServer.SmtpServer>(
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Papercut
2+
//
3+
// Copyright © 2008 - 2012 Ken Robertson
4+
// Copyright © 2013 - 2025 Jaben Cargman
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
19+
namespace Papercut.Infrastructure.Smtp.RateLimiting;
20+
21+
/// <summary>
22+
/// The outcome of a single <see cref="SmtpRateLimiter.TryAcquire" /> attempt.
23+
/// </summary>
24+
/// <param name="IsAllowed">Whether the message may be accepted.</param>
25+
/// <param name="Count">Messages accepted in the current window, including this one when allowed.</param>
26+
/// <param name="RetryAfter">Time remaining until the window resets. Zero when allowed.</param>
27+
public readonly record struct RateLimitDecision(bool IsAllowed, int Count, TimeSpan RetryAfter);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Papercut
2+
//
3+
// Copyright © 2008 - 2012 Ken Robertson
4+
// Copyright © 2013 - 2025 Jaben Cargman
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
19+
using SmtpServer;
20+
using SmtpServer.Mail;
21+
using SmtpServer.Protocol;
22+
using SmtpServer.Storage;
23+
24+
namespace Papercut.Infrastructure.Smtp.RateLimiting;
25+
26+
using ILogger = Serilog.ILogger;
27+
28+
/// <summary>
29+
/// Mailbox filter that enforces a message reception rate limit, letting developers
30+
/// test how their application behaves against a mail server that is throttling them.
31+
///
32+
/// Returning false from CanAcceptFromAsync would reject with a hardcoded 550, so this
33+
/// throws SmtpResponseException instead -- the SmtpServer session loop writes the
34+
/// carried response back to the client verbatim, which is what allows a configurable
35+
/// reply code (421/451/452/550).
36+
/// </summary>
37+
internal sealed class RateLimitMailboxFilter(SmtpRateLimiter rateLimiter, ILogger logger) : IMailboxFilter
38+
{
39+
public Task<bool> CanAcceptFromAsync(
40+
ISessionContext context,
41+
IMailbox from,
42+
int size,
43+
CancellationToken cancellationToken)
44+
{
45+
var decision = rateLimiter.TryAcquire();
46+
47+
if (decision.IsAllowed)
48+
{
49+
logger.Verbose(
50+
"SMTP message accepted against rate limit {RateLimit} ({Count} so far this window)",
51+
rateLimiter.Limit,
52+
decision.Count);
53+
54+
return Task.FromResult(true);
55+
}
56+
57+
logger.Warning(
58+
"Rejected SMTP MAIL FROM command from {RemoteIp} with {ReplyCode} - rate limit {RateLimit} reached, resets in {RetryAfter}",
59+
context.GetRemoteIpAddress(),
60+
(int)rateLimiter.Limit.ReplyCode,
61+
rateLimiter.Limit,
62+
decision.RetryAfter);
63+
64+
// quit: true closes the session after the reply. Without it the session loop
65+
// appends ", N retry(ies) remaining." to the message, which is noise in a
66+
// response the client is meant to parse.
67+
throw new SmtpResponseException(
68+
new SmtpResponse(rateLimiter.Limit.ReplyCode, rateLimiter.Limit.ReplyMessage),
69+
quit: true);
70+
}
71+
72+
public Task<bool> CanDeliverToAsync(
73+
ISessionContext context,
74+
IMailbox to,
75+
IMailbox from,
76+
CancellationToken cancellationToken)
77+
{
78+
// The limit counts messages, not recipients -- it is applied once at MAIL FROM.
79+
return Task.FromResult(true);
80+
}
81+
}

0 commit comments

Comments
 (0)