Skip to content

Rate-limit SMTP messages, cap subjects at 998 characters and fix the suppressed details - #130

Merged
heliocosta-dev merged 5 commits into
mainfrom
fix/send-path-gaps
Oct 15, 2026
Merged

heliocosta-dev merged 5 commits into
mainfrom
fix/send-path-gaps

Conversation

@heliocosta-dev

Copy link
Copy Markdown
Member

Gaps found while reviewing #129, which records sends refused because every recipient is suppressed. Each commit explains its own change; the list below is what clients and operators will notice.

Behaviour changes (release notes)

  • The SMTP relay is rate-limited per message. Each MAIL FROM counts against the API key's per-minute budget, the same one the key's API requests spend (API_RATE_LIMIT_PER_MINUTE, default 600). It also counts against 3,000 a minute per team in the relay. Past either limit the relay answers 451 4.7.1 with the seconds left, before any of the message is sent, so the client keeps the message and retries.
  • Subjects take up to 998 characters, the RFC 5322 line limit. This applies to POST /emails, each POST /emails/batch item, broadcast create and update, and the dashboard's broadcast test send; templates and the dashboard's broadcast editor already had it. A longer subject gets 422 validation_error, and the SMTP relay answers 554. Inbox replies, forwards and drafts keep their 2,000. Resend documents no limit for these endpoints, so the limit is listed among the Resend deltas.

Dashboard

  • Suppressed details on the email page now finds the suppression of every recipient (to, cc and bcc). It matches them the way sends do, so a display name or capital letter no longer misses the entry, and each match gets its own Remove.
  • The drawer's note named a 422 suppressed_recipient that doesn't exist. It now gives the real rule and the real 422 all_recipients_suppressed.
  • A send refused before its body was stored (recorded once Record a suppressed email when a send is refused for all-suppressed recipients #129 lands) says so, instead of "Body purged after retention window".

Trust & safety

At a suspension, the campaign fingerprint capture now reads, for each sender and subject, the newest email that still has its body. A newer row without a body no longer hides an older one's fingerprints.

Docs

  • The Resend migration guide and its agent prompt warn that an all-suppressed send answers 422 all_recipients_suppressed; Resend accepts it and marks the email suppressed.
  • The suppressions page now says the SMTP relay answers 550 there, not 422.
  • The pt-BR API reference gains the sentence on which suppression origins count.

All of it is in en and pt-BR.

Checks

pnpm lint, pnpm typecheck, turbo run test (4,707 tests) and pnpm db:generate pass on this branch. Every behaviour fix has a test that fails without it.

Follow-ups (not in this PR)

  • The Suppressed details drawer still gives bounce advice for every origin. Its Remove also lacks the confirm dialog and role gate the suppressions page uses (the server already requires an admin).
  • The timeline's "future sends blocked" line overstates for unsubscribes.
  • The API's per-key rate limit isn't described in the API reference.

🤖 Generated with Claude Code

heliocosta-dev and others added 5 commits October 15, 2026 12:09
…s body

At a phishing suspension the capture reads one stored email per sender
and subject among the team's latest. It took the newest row of each, so
a newer row without a body (one the retention purge emptied, or one
whose body was never stored) hid an older row of the same campaign that
still had it, and the capture lost the campaign's link domains, phone
numbers and text hash.

Each sender and subject now reads its newest email that still has a
body, and falls back to its newest only when none of its scanned rows
has one. The scan size, and the cap on distinct senders and subjects
counted newest first, stay as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The suppressed details drawer on the email page looked its suppression up
by the first to address only, through a search on the stored address and a
case-sensitive comparison, so a display name or a capital letter missed the
entry and other suppressed recipients never showed. A new
emails.suppressions.forEmail query matches every to, cc and bcc recipient of
the email by the same normalized hashes the send path checks, within the
caller's team, and the drawer lists each match with its origin, date and its
own Remove action. An entry whose address was erased still matches by hash
and shows no address.

The drawer's note named a 422 suppressed_recipient that does not exist and
said every entry blocks every send. It now says an unsubscribe only blocks
topic sends and broadcasts, and that a send left with no to recipient is
refused with 422 all_recipients_suppressed.

A send refused at accept because every to recipient was suppressed never had
its body stored, so the content panel says that instead of claiming the body
was purged after the retention window. The line keys on the email's
suppressed event coming from the API rather than the worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /emails, each item of POST /emails/batch and broadcast create and
update took a subject of any length, held back only by the request body
limit, while templates and the dashboard's broadcast editor already
stopped at 998 characters, the RFC 5322 line limit, each with its own
literal.

MAX_SUBJECT_LENGTH in core now holds that limit and every one of them
uses it: the API's request schemas, so the OpenAPI document and the MCP
tools built on them carry maxLength; the dashboard's broadcast and
template routers, including the broadcast test send, which had no cap;
and the SMTP relay, which answers 554 as it does for its other Subject
checks. Inbox replies, forwards, drafts and the inbox composer keep the
2000 that Resend documents for them.

The API reference lists the limit among the Resend deltas, in en and
pt-BR, since Resend documents none for these endpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…with the API

The relay only capped connections, so an authenticated session could submit
messages with no per-minute bound. Each MAIL FROM now counts against the
key's minute bucket in api_rate_limits, the one its API requests spend, and
against a per-team counter in the relay process, with the API's defaults
(API_RATE_LIMIT_PER_MINUTE per key, 3,000 per team). Past either limit the
relay answers 451 4.7.1 with the seconds left in the minute, before any of
the message is received. A transaction opened after RSET counts again, and a
session that has not authenticated never reaches the check.

The fixed-window counter and the key bucket upsert move from the API into
packages/core so both surfaces share them; the API's limits answer exactly as
before. The per-key default becomes one exported constant that the env
schema, the API, its MCP gate and the relay read.

The self-hosting docs, in English and Brazilian Portuguese, describe the
shared budget and the 451 4.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The migration copies Resend's bounce, complaint and manual suppressions,
and MillionSend refuses a send whose every `to` recipient is suppressed
with 422 all_recipients_suppressed, where Resend accepts it and records the
email as suppressed. The API reference listed this as a deliberate
difference, but the migration guide and the agent prompt did not, so code
that kept mailing those addresses would meet an unexpected error after
cutover.

The guide's list of differences gains that case, with what to do (treat
the error as "not sent" rather than retry it) and how one such item fails
a strict batch whole; the agent prompt says the same, in both languages.
The pt-BR API reference also gains the sentence the English one already
had on which suppression origins count for transactional and topic sends.

The suppressions page the new item links to said the SMTP relay answers
that 422 too; it answers 550 with the same message, and now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@heliocosta-dev
heliocosta-dev merged commit 83a48de into main Oct 15, 2026
5 checks passed
@heliocosta-dev
heliocosta-dev deleted the fix/send-path-gaps branch October 15, 2026 15:28
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