Skip to content

fix(conv): admit 30 MiB relay messages and drop the relay per-IP throttle - #892

Merged
kmonsoe merged 1 commit into
feat/email-relay-inboundfrom
fix/email-relay-body-limit-and-throttle
Sep 4, 2026
Merged

fix(conv): admit 30 MiB relay messages and drop the relay per-IP throttle#892
kmonsoe merged 1 commit into
feat/email-relay-inboundfrom
fix/email-relay-body-limit-and-throttle

Conversation

@kmonsoe

@kmonsoe kmonsoe commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Stacked on #890. Two defects found while planning the hosted inbound MX that will feed POST /v1/conversations/email/relay.

1. The 30 MiB relay cap was unreachable

bootstrap-app.ts registers the global JSON parser at 4mb. A base64 JSON envelope is ~1.37× the message, so any email over ~3 MB got Express's {"statusCode":413,"message":"request entity too large"} before the controller ran. Verified against the deployed 5.14 API: a 5 MB POST is refused by Nest, while a 35 MB POST passes the platform ingress untouched, so the global parser was the only limit.

Fix: a new email-relay.constants.ts is the single source for the path, signature header and limits (EMAIL_RELAY_MAX_RAW_BYTES = 30 MiB, EMAIL_RELAY_BODY_LIMIT_BYTES ≈ 41 MiB derived from it). Bootstrap mounts, on exactly that path and ahead of the global parser:

  • a gate that answers 401 relay signature missing for a POST without x-munin-relay-signature before any body is read, so unsigned traffic cannot make the server buffer 40 MB per request;
  • a route-scoped express.json with the larger limit whose verify sets req.rawBody exactly as Nest's own raw-body parser does.

body-parser 2.3.0 skips a request whose body has already been read, so the global 4mb parser never re-parses this route and stays untouched for everything else.

2. Per-IP throttling capped the whole platform's inbound mail

The controller was @PublicController(..., { throttle: true }): 60/min and 1000/hour per client IP. Every customer's forwarded mail arrives from the operator's one or two MX addresses, so that was a platform-wide ceiling of 1000 emails an hour. The endpoint is HMAC-authenticated, so the throttle added nothing; it is removed. Signature verification still precedes any DB work.

Tests

The integration test now boots via createApp(AppModule) — the previous NestFactory.create never exercised bootstrap's parsers. New cases:

  • ~6.2 MiB signed message → 201 ingested, row present
  • same payload unsigned → 401 relay signature missing, no row
  • 30 MiB + 1 raw → 413 message too large from the controller, not Express
  • 65 consecutive signed posts → all 201, none 429

Negative control: with { throttle: true } restored and the route-scoped mount removed, exactly these four fail. Full backend-core suite green (153 files, 1980 tests).

Changeset: @getmunin/backend-core patch.

🤖 Generated with Claude Code

…ttle

Two defects in POST /v1/conversations/email/relay, both found against the
deployed 5.14 backend.

The 30 MiB relay limit was unreachable. createApp registers the global JSON
body parser with a 4mb limit, and a relay envelope carries the message as
base64, so the JSON is ~1.37x the message size: anything over roughly 3 MB
was answered by Express with {"statusCode":413,"message":"request entity
too large"} before the controller ran. Verified by POSTing 5 MB to the live
API — Nest answers 413 while the platform ingress passes 35 MB through, so
the limit was ours. The relay path now gets its own express.json parser
mounted ahead of the global useBodyParser call, sized from the same
constant the controller enforces (EMAIL_RELAY_BODY_LIMIT_BYTES: the base64
expansion of EMAIL_RELAY_MAX_RAW_BYTES plus 1 MiB of envelope, ≈41 MiB) and
setting req.rawBody exactly as Nest's rawBody: true verify hook does, so
HMAC verification over the raw bytes is unchanged. body-parser 2.x skips a
request whose stream is already finished, so the global parser does not
double-parse, and the 4mb limit still applies to every other route. A POST
on that path with no x-munin-relay-signature header is refused 401 by a
tiny middleware before the body is read, so unsigned traffic cannot make
the server buffer 40 MB per request.

The controller was declared with throttle: true, which applies the public
per-client-IP throttle (60/min, 1000/hour). Every customer's relayed mail
arrives from the operator's one or two MX addresses, so that was a
platform-wide ceiling of 1000 inbound emails per hour. The endpoint
authenticates every request by HMAC before touching the database, so the
IP throttle added nothing and is removed.

The integration test now boots through createApp so the route-scoped
parser is under test, and adds: a ~6 MiB message (text part plus a large
base64 attachment) is ingested; an unsigned oversized post is 401 before
the controller; a message over the relay maximum still gets the
controller's own 413; and 65 signed posts in a row all return 201. All
four fail on the previous commit (413 from Express, 429 from the
throttler).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kmonsoe
kmonsoe disabled the stack merge September 4, 2026 10:29
@kmonsoe
kmonsoe merged commit e85d6d1 into main Sep 4, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant