All posts

Pacing cold email at the moment of sending

Why send capacity is reserved right before the provider call, how campaigns share a mailbox fairly, and why replies, bounces, and unsubscribes always win.

Cold outbound is a balancing act. Send enough to be useful to the customer, slowly enough that their mailboxes stay healthy, and never in a way that makes a prospect's inbox worse. At Roger, every customer sends from a set of mailboxes, and I built the parts that decide what goes out, from which mailbox, and when.

This isn't a post about deliverability tricks. It's about the engineering that makes sending predictable: where capacity is enforced, how campaigns share a mailbox, and why stopping matters more than sending.

Planning is a forecast

The send planner runs on a schedule. For each mailbox it builds a capacity window: today's effective limit, how much of it is used, and when it resets. Then it selects the next due step of every eligible sequence (one step per sequence, in a single query) and fans out one send job per step.

That plan is only a forecast. Between planning and the provider call, a lot can happen: a retry from an earlier run, two planner runs overlapping during a deploy, a send that took longer than usual. Early on, the result was the thing you least want from a cold mailbox: several messages leaving within a few minutes of each other.

So the real gate moved to the last possible moment.

Admission at the provider boundary

Immediately before a message is handed to the provider, the worker asks the database for permission. A short transaction locks the mailbox row, checks that the mailbox is allowed to send right now (not paused, finished warming, recent health data, under today's cap), and then reserves two things at once: a slot from today's capacity and the earliest time the next automated send may happen.

-- inside one short transaction, before any network I/O
SELECT … FROM mailbox WHERE id = $1 FOR UPDATE;
-- check: not paused, warmed up, health is fresh, under the daily cap
UPDATE mailbox
SET used_today = used_today + 1,
    next_send_at = now() + pacing_interval
WHERE id = $1;

There's deliberately no network I/O inside that transaction. Holding a row lock while waiting on SMTP is how one slow provider turns into a stalled fleet. If admission says no, the step is deferred and the planner picks it up on a later run.

Admission applies to automated sends: scheduled follow-ups and proactive messages. A real reply to a prospect who wrote back is a conversation, not a campaign, and bypasses the pacing policy.

Don't refund uncertain sends

What should happen to the reserved slot if the send fails?

The intuitive answer is to give it back. We don't. A failed or uncertain attempt keeps its permit and its share of the day's capacity. If a provider is having a bad few minutes, refunding every failure turns it into a burst of retries the moment it recovers. Keeping the permit trades a little throughput for behavior that stays bounded when things go wrong.

The same thinking applies to ambiguous success. With the Gmail API, the worker records that Gmail accepted the message before looking up its RFC Message-ID. If the lookup then fails, the message is never sent again. A missing piece of metadata is a problem we can repair later; the same email arriving twice in a prospect's inbox is not.

Sharing a mailbox fairly

Several campaigns can draw from the same mailboxes, and some campaigns are marked as priority. The first scheduler simply served priority campaigns first. Predictably, a busy priority campaign starved everything else on the same mailbox.

The planner now allocates each mailbox's capacity by weighted round-robin. Every campaign with due work gets a turn, priority campaigns get extra turns per round, and buckets are ordered by their oldest due step so nothing waits forever. Sends already made today are charged against a campaign's turns first, so a planner run halfway through the day doesn't hand out the morning's allocation twice.

We kept the old priority-first strategy in the code alongside the new one, so the two could be compared on real data before the switch.

Timing matters as much as allocation. Steps are scheduled at a random time inside business hours in the campaign's timezone, with weekends off unless a campaign opts in. Nothing lands at exactly 9:00.

Ramp on evidence, not the calendar

New mailboxes start with a small daily limit and earn more over time. The first version stepped the limit up on a fixed schedule, which had an obvious flaw: a mailbox that sat idle for a week "graduated" without ever proving anything.

Now the ramp only counts completed days on which the provider actually accepted a send from that mailbox. Idle days and warmup days don't count, and the limit only advances when the mailbox has a recent, good health measurement. Missing data never counts as healthy. An earlier version filled gaps with a default "healthy" score, and removing it was one of the more important changes we made.

Stopping is the most important feature

The most consequential code in an outbound system isn't the code that sends. It's the code that stops sending.

These rules have to hold no matter what else is going on, and we learned that the hard way. While a new sender-protection policy was running in shadow mode, a gap meant hard bounces weren't stopping follow-ups. The fix was to make bounces stop the sequence unconditionally, independent of any policy's mode.

Bounce handling itself starts with deterministic parsing. Delivery status notifications have a structured message/delivery-status part, and the worker reads it to sort notices into invalid recipients, temporary failures, authentication problems, policy blocks, and reputation blocks before any model sees the message. It's careful about what counts as evidence, too: a bounce that quotes the original campaign email doesn't become a "block" just because the quoted copy mentions one.

Ship protection in shadow mode

The sender-protection policy moves a mailbox through states as signals get worse (normal, slowed, cooling down, paused) and back up through staged recovery. Before it is allowed to act, it runs in shadow mode, recording what it would have done. Enforcement is switched on by a single timestamp read by every runtime, so the web app and the worker change behavior at the same moment.

Two rules kept its decisions honest:

What I'd keep

Good outbound infrastructure is mostly restraint, written down as code.

All posts