Skip to content

Commit eb4f18d

Browse files
authored
Merge pull request #377 from ChangemakerStudios/feature/smtp-rate-limit
Add configurable SMTP message rate limiting
2 parents d8993a5 + 16244a0 commit eb4f18d

17 files changed

Lines changed: 958 additions & 13 deletions

File tree

DOCKERHUB.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
![Papercut Logo](https://raw.githubusercontent.com/ChangemakerStudios/Papercut/develop/graphics/PapercutLogo.png)<br>
1+
![Papercut Logo](https://raw.githubusercontent.com/ChangemakerStudios/Papercut/develop/graphics/PapercutLogo.png)<br>
22
*The Simple Desktop Email Helper*
33

44
## The problem
@@ -90,6 +90,8 @@ docker run -d \
9090
- `SmtpServer__MessagePath` - Path where emails are stored (default: /app/Incoming)
9191
- `SmtpServer__LoggingPath` - Path for log files (default: /app/logs)
9292
- `SmtpServer__AllowedIps` - IP allowlist for SMTP connections (default: "*" = all IPs allowed)
93+
- `SmtpServer__RateLimit` - Message reception rate limit as `<count>/<window>`, e.g. `500/1h` (default: "*" = no limit)
94+
- `SmtpServer__RateLimitReplyCode` - SMTP reply code returned once the rate limit is hit (default: 451)
9395
- `Urls` - HTTP server URLs (default: http://0.0.0.0:8080)
9496
- `HttpPathPrefix` - Serve the web UI and API under a path prefix, e.g. `/webmail` (default: empty = serve at root)
9597

docs/service.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Service & Web UI
1+
# Service & Web UI
22

33
The **Papercut SMTP Service** is an optional background component that receives email even when the desktop app isn't running — as a Windows Service or a [Docker container](docker.md) — and includes a browser-based UI for viewing messages.
44

@@ -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+
```text
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;
@@ -58,9 +60,29 @@ protected override void Load(ContainerBuilder builder)
5860
ctx =>
5961
{
6062
var ipAllowedList = ctx.ResolveOptional<IPAllowedList>() ?? IPAllowedList.AllowAll;
61-
var logger = ctx.Resolve<ILogger>().ForContext<IpAllowlistMailboxFilter>();
63+
var logger = ctx.Resolve<ILogger>();
64+
var ipLogger = logger.ForContext<IpAllowlistMailboxFilter>();
65+
66+
// Rate limiting is opt-in and only registered by the service host, so
67+
// hosts that never configure it (the UI) resolve nothing and pay nothing.
68+
var rateLimiter = ctx.ResolveOptional<SmtpRateLimiter>();
69+
var rateLimitLogger = logger.ForContext<RateLimitMailboxFilter>();
70+
6271
return new DelegatingMailboxFilterFactory(
63-
_ => new IpAllowlistMailboxFilter(ipAllowedList, logger));
72+
_ =>
73+
{
74+
var ipFilter = new IpAllowlistMailboxFilter(ipAllowedList, ipLogger);
75+
76+
if (rateLimiter is null || rateLimiter.Limit.IsUnlimited)
77+
{
78+
return ipFilter;
79+
}
80+
81+
// Allowlist first: connections Papercut was never going to
82+
// accept should not consume the caller's quota.
83+
return new ChainedMailboxFilter(
84+
[ipFilter, new RateLimitMailboxFilter(rateLimiter, rateLimitLogger)]);
85+
});
6486
}).As<IMailboxFilterFactory>();
6587

6688
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)