Skip to content

feat(mail): rate-limit sends per ESP - #87

Merged
clbotdev merged 4 commits into
mainfrom
issue-51-send-rate
Oct 19, 2026
Merged

clbotdev merged 4 commits into
mainfrom
issue-51-send-rate

Conversation

@clbotdev

Copy link
Copy Markdown
Contributor

Closes #51. Part of #79.

  • ESPs get an optional maxSendRate (messages/second); when unset, Amazon SES defaults to 14/s and Resend to 2/s (shared from the contract). Migration 0013.
  • sendMail reserves a slot per ESP with a Redis GCRA script, so sends through one ESP are spaced evenly across all workers and processes.
  • A send that would wait over 30 s, or that the provider throttles (421/45x, SES "Throttling"), is deferred: sequences reschedule the row without counting a failed attempt; transactional jobs move to delayed without using an attempt.
  • REST, MCP and the ESP dialog expose the rate; docs in sending settings.
  • CI: a Redis service for the limiter test.

Testing

  • Unit: limiter spacing across concurrent callers and defer-past-cap (real Redis), throttle classification, sendMail deferral, sequence and transactional deferral paths.
  • Browser E2E: set 2/s in the ESP dialog; a broadcast to 8 contacts arrived 473–526 ms apart in Mailpit (3.5 s total); clearing the field restores the default.

POST /sequences/:id/emails/:emailId/test (and the MCP tool
send_sequence_email_test) renders the email exactly like a real send,
through the delivery source the send would use, with a [Test] subject.
It defaults to the signed-in user, takes up to five addresses, skips
suppressed ones and is capped at 50 recipients per team per hour.

Tracking tokens carry a contact id that matches nobody and the
unsubscribe link uses a test token whose page explains it was a test.
The email records lastTestSentAt. The broadcast editor and the
full-screen email editor get a Send test dialog.

The per-recipient render pipeline moves to mail/campaign.ts so real and
test sends share it.

Refs #34, #79
Every send now carries a text/plain alternative generated from the final
HTML (links as "text (url)", bulleted lists, no images, styles or
preheader), or the caller's own text for transactional sends (new
optional `text`, stored on the row).

Marketing emails link a hosted web version from the managed footer, also
available as the {{ view_in_browser_url }} merge tag. GET /view/:token
serves it for the signed recipient with their merge data and no
tracking, as a sandboxed document. The editor default no longer starts
with a plain "View in browser" line that linked nowhere.

Refs #35, #79
sendMail had no replyTo, so a transactional send's stored Reply-To (and
the organization grant's) never reached the recipient. It is now sent,
and team ESPs get an optional reply-to (REST, MCP, the ESP dialog) used
for broadcasts, sequences, tests and transactional sends without their
own. Organization grants fall back to their ESP's reply-to.

Refs #47, #79
Each ESP gets an optional maxSendRate (messages per second); when unset,
Amazon SES ESPs default to 14/s and Resend to 2/s. sendMail reserves a
slot per ESP with a Redis GCRA script, so sends through one ESP are
spaced evenly across every worker and process. A send that would wait
more than 30s, or that the provider throttles (421/45x, SES
"Throttling"), is deferred: sequences reschedule the row without
counting a failed attempt and transactional jobs are moved to delayed
without using an attempt.

REST, MCP and the ESP dialog expose the rate. CI gets a Redis service
for the limiter test.

Refs #51, #79
@clbotdev
clbotdev merged commit 8ae8f2e into main Oct 19, 2026
5 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.

Per-ESP send-rate limiting (e.g. SES max send rate)

2 participants