Skip to content

feat(delivery): daily send limit for warming up a domain or SES account - #89

Merged
clbotdev merged 6 commits into
mainfrom
issue-67-warm-up
Oct 19, 2026
Merged

clbotdev merged 6 commits into
mainfrom
issue-67-warm-up

Conversation

@clbotdev

Copy link
Copy Markdown
Contributor

Closes #67. Part of #79.

The existing daily quotas only covered shared organization grants; a beehiiv switcher bringing their own SES (a team ESP) had no cap at all.

  • ESPs get an optional dailySendLimit (warm-up cap): broadcast and sequence emails over it in a UTC day are deferred to just after midnight instead of failing; transactional mail is never held back. Counted in Redis per ESP and day; the ESP list shows "Today: x / y". Migration 0014.
  • Campaign mail that hits an organization grant's daily/monthly quota now waits for the reset instead of counting as a failed attempt (which eventually marked contacts failed).
  • New docs page "Warming up a domain or SES account" (when you don't need to, SES sandbox and quotas, ramp table, engaged readers first, what to monitor), linked from getting started, the SES page and sending settings.

Testing

  • Unit: daily cap (real Redis), marketing-only enforcement, quota deferral.
  • Browser E2E: limit 3 set in the ESP dialog; a broadcast to 5 contacts sent 3 and held 2 until after midnight UTC without failures; ESP list shows "Today: 3 / 3"; transactional mail still sent; after raising the limit and simulating the next day the rest went out and the broadcast completed.

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
POST /contacts/import (MCP import_contacts) upserts up to 1000 rows per
request by email. Each row's source status is interpreted with a shared
vocabulary (beehiiv Active/Paused/Inactive/Bounced/Pending, Mailchimp
cleaned, ...) and a status only gets stricter: nobody is re-subscribed,
suppressed addresses stay unsubscribed, bounced and complained rows are
suppressed, paused contacts are tagged. Tags and custom fields merge,
the earliest signup date wins, imports don't trigger sequences, and
subscribed contacts over the plan limit are skipped. The response counts
created/updated/unchanged/suppressed rows and lists skipped rows with
reasons.

GET /contacts/export (MCP export_contacts) returns CSV with status,
tags, created_at, suppression reason and custom field columns; it
round-trips through the importer. Cells are formula-injection safe.

The Contacts page gets an Import dialog (CSV parsed in the browser,
column mapping pre-filled from common export headers, chunked upload,
report) and an Export button.

Refs #38, #79
Team ESPs (BYO SES, Postmark, ...) had no daily cap; the existing quotas
only cover shared organization grants. An ESP can now set
dailySendLimit: broadcast and sequence emails over it in a UTC day are
deferred to just after midnight instead of failing, while
transactional mail is never held back. The count is kept in Redis per
ESP and day, and the ESP list shows today's progress.

Campaign mail that hits an organization grant's daily or monthly quota
is now deferred until the quota resets instead of counting as a failed
attempt (which eventually marked the contact failed).

Adds a docs page on warming up a domain or SES account (when you don't
need to, SES sandbox and quotas, a ramp table, engaged readers first,
what to monitor), linked from getting started, the SES page and sending
settings.

Refs #67, #79
@clbotdev
clbotdev merged commit 2c133ff into main Oct 19, 2026
5 checks passed
@clbotdev clbotdev mentioned this pull request Oct 19, 2026
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.

Day-one warm-up answer: warm-up guide and self-serve daily send caps for new domains / SES accounts

2 participants