LetterDuck
Dispatch

How we actually warm up email

A working log of our warm-up system: what is enforced in code, what is still theory at our volume, and the two decisions we have not settled.

Published Aug 7, 2026

Our warm-up routine sends zero emails whose only purpose is to be warm-up. No seeded inboxes, no bot replies, no automated rescue of our own mail out of a spam folder. What runs instead is a daily cap the send path computes before every campaign, two thresholds that halt sending without asking a human, an audit script with five sections, and a probe email that travels our receiving path every two hours. This is a log of that system as of August 2026, and the honest version of it includes the parts that, at our volume, are still theory.

The word warm-up has two meanings that have drifted apart. One is a discipline: raise volume slowly on a new domain so mailbox providers can form an opinion out of real behavior. The other is a product category: a monthly subscription that wires your mailbox into a network of other mailboxes, which open your mail, reply to it, and drag it out of spam on a schedule. We practice the first. We refuse the second, and the reason has less to do with ethics than with the wiring of our own system.

A seed network would poison our own safety net

The argument against buying engagement is usually made on principle. Ours is mechanical, and it is specific to how the send path is built.

LetterDuck pauses itself on two numbers: the spam complaint rate and the bounce rate, both computed over a rolling 30-day window from the delivery events our relay reports back through a webhook. Those two numbers are the only thing standing between a bad list and a burned domain. They work because every event feeding them came from a person who pressed a button, or from a real mailbox refusing a real message.

Now point a warm-up network at the same domain. The denominator fills with mail nobody wanted and nobody read, sent to accounts that exist to receive it. Complaint rate falls, because seed accounts never complain. Bounce rate falls, because seed accounts never bounce. Every gauge we built to catch a problem gets quieter exactly when the underlying list is getting worse. We would be spending money to blind the instrument.

The second reason is that the warmth does not go where you need it. Gmail's filtering decisions are personalized per recipient: the model that decides whether your newsletter lands in Sarah's inbox is built substantially out of what Sarah, and people who behave like Sarah, have done with mail from your domain. Engagement generated inside a closed network of trading mailboxes is engagement from people who are not on your list and never will be. It moves an aggregate that is not the aggregate being consulted.

The third reason is plainer. Seed networks are a pattern, and patterns are what filters are good at. A cluster of accounts that reply to each other with the reliability of a cron job does not read like correspondence.

The limit of our position is real, so here it is. Cold outreach has no opted-in audience to ramp against, which means nothing genuine to warm with, and the case for a seed network is at its strongest there. We run newsletters and business inboxes, and our bias follows our workload. If you want the category surveyed rather than dismissed, we ranked the main subscriptions by mechanism and published price in our warm-up tools roundup.

What is actually cold: the IP or the domain

Mailbox providers score the sending IP and the sending domain separately, and which one you can influence depends entirely on your infrastructure.

We send through Resend's shared pool. Those IPs carry years of accumulated history from thousands of senders, maintained by a company whose business depends on keeping them clean. Nothing we do in our first month registers there. What has no history at all is the domain, and that is the whole surface available to us.

A dedicated IP inverts the problem: you own the reputation, which means you also own the obligation to keep it warm with steady daily volume forever. Postmark's guidance puts the worthwhile threshold near 300,000 emails a month, and AWS SES points to roughly 100,000 a day (both from their published docs, checked June 2026). Our own line, stated as an opinion rather than a citation: below about 50,000 emails a month a dedicated IP is a mistake, because it spends most of its life idle and a cold dedicated IP delivers worse than a busy shared pool.

We are not remotely close. We run dozens of sends a day. The dedicated-IP question is not a decision we are deferring, it is a decision that does not exist for us yet, and pretending otherwise would be the kind of thing this log exists to avoid.

The shared pool has its own tax: your neighbors are part of your delivery. Part of a relay's fee buys pool policing, meaning suppression enforcement and eviction of abusers, so pick a relay that visibly does it.

The ramp, as it is written

Here is the function, lightly trimmed from src/lib/throttle.ts:

export function rampCapForDay(dayN: number) {
  if (dayN <= 7)  return { phase: 'warm',   cap: Math.round(150 * Math.pow(1.4, Math.max(0, dayN - 1))) };
  if (dayN <= 28) return { phase: 'ramp',   cap: Math.round(2057 * Math.pow(1.2, dayN - 7)) };
  return { phase: 'steady', cap: Number.POSITIVE_INFINITY };
}

Day N counts whole days since the entity's first recorded send, so day 1 is the first day the domain ever sent anything. The warm phase follows Resend's documented domain ramp (about 150 on day one, multiplied by roughly 1.4 daily), which we adopted rather than invented because the relay watches far more sending than we ever will, and which we broke down number by number separately. The ramp phase compounds 20% a day through day 28. After that the ramp stops being the constraint and the relay's cap is.

Then one line decides what actually happens:

const dailyCap = paused ? 0 : Math.min(rampCap, PROVIDER_DAILY_CEILING);

PROVIDER_DAILY_CEILING is 95. The Resend free tier allows 100 sends a day, and the five we hold back are headroom for test sends. So the effective cap on any given day is the smaller of the theory and the ceiling, and the ceiling has never once lost.

DayPhaseRamp cap in codeWhat we can actually send
1warm15095
2warm21095
4warm41295
7warm1,12995
8ramp2,46895
14ramp7,37195
21ramp26,41095
28ramp94,63395
29+steadyno ramp limit95

The right-hand column is the point of the table. Every row is 95. At our current scale the ramp is not a governor, it is a placeholder that will start mattering the day we move off the free tier, and the reason we wrote it now is that switching relays should be a config change and not a rewrite of the send path.

Two more honest notes about that code, since a log that only reports the flattering parts is marketing. First, the ramp-phase base constant is 2,057, which was written from the "about 2,000 a day by day seven" target in our playbook rather than computed from the warm curve, and the warm curve actually lands at 1,129 on day 7. Days 7 and 8 do not meet. Nothing has ever been affected, because the ceiling clamps both to 95, and we would rather publish the seam than quietly smooth it before anyone looks. Second, the counter resets on the UTC day boundary, not the sender's local midnight, which is a detail worth knowing before you schedule an evening campaign from a timezone west of London.

If you want the same curve computed against your own starting volume and list size, the warm-up schedule builder prints it as a day-by-day table.

The two gates that do not ask permission

The caps are the boring half. This is the half that matters:

const COMPLAINT_PAUSE = 0.003;  // 0.3%
const BOUNCE_PAUSE    = 0.02;   // 2%

Both rates are computed over the trailing 30 days of send events for that workspace. If the complaint rate is at or above 0.3%, or the bounce rate is above 2%, dailyCap becomes zero and the send path refuses to send anything at all, with the reason attached so the deliverability panel can display it. There is no override button, no "send anyway" affordance, and no human judgment call in that loop. That was deliberate: the moment you are most tempted to argue with a complaint rate is the moment you are least qualified to.

The 0.3% figure is Google's, from the bulk-sender rules published in February 2024 and updated through 2025. At or above that line Gmail withholds delivery mitigation until you have been back under it for seven consecutive days, which means one bad send costs a week even after you fix it. The 2% bounce ceiling is standard list hygiene, sharpened by where we are headed: AWS SES pauses entire accounts at 5% bounce and 0.1% complaint, and we would rather have built the reflex before we need the platform that enforces it.

The limitation, stated because it is ours: at dozens of sends a day these rates are statistically ridiculous. One complaint against 200 sends is 0.5%, which pauses us cold. That is the correct failure mode rather than a bug to tune around. At small volume one annoyed recipient really is a large share of everything anyone thinks about our domain, and stopping to look costs us nothing. The gate gets less twitchy on its own as volume grows.

The checkup, five sections

Warm-up at our scale is not a send, it is an inspection. The routine is one command:

node scripts/warmup-check.mjs

It is read-only. It never sends, which matters because a script that both audits and transmits will eventually transmit during an audit. It prints PASS, WARN, or FAIL across five sections:

1. Sending identity. Which domain the newsletter goes out from, the reply-to address, and whether that domain is verified with the relay. An unverified sending domain is not a slow leak, it is mail that fails or lands in spam immediately.

2. Authentication. SPF on the sending domain, the DKIM public key at the relay's selector, and the DMARC record on the root with its policy stage read out. This is the section that can be a real emergency.

3. List health and reputation metrics. Active subscribers, delivered count, bounce rate, complaint rate, and the size of the suppression list.

4. The inbound watchdog. How long ago the last canary probe completed its round trip, plus the count of Gmail mirror successes and failures in the last 24 hours.

5. Cloudflare's delivery ledger. Seven days of email-routing events straight from the Cloudflare GraphQL API, grouped by action and status.

What counts as an emergency, and what is noise

An authentication FAIL is the only true emergency in that output. Gmail has hard-rejected unauthenticated mail with a 550 since November 2025, and Microsoft since May 2025, so a missing SPF, DKIM, or DMARC record is not a deliverability risk, it is mail that does not exist. Everything stops until it is fixed.

A sending domain that is not registered with the relay is the second-tier problem: real, fixable in minutes, and it makes every campaign a coin flip until it is.

Bounce rate below about 50 delivery attempts is noise, and the script says so in its own output rather than making us remember. One bounce out of nine attempts prints as 11% and means nothing. Below that threshold the script reports PASS with the volume caveat attached, and the only thing worth confirming by hand is that the hard bounces landed in the suppression list.

The Cloudflare forward and deliveryFailed counts in section five are legacy. They date from the era when our Gmail mirror used SMTP forwarding, which Gmail was DMARC-rejecting about 73% of the time, and they age out of the seven-day window on their own. The numbers to trust now are the mirror_ok and mirror_fail counts in section four.

A canary older than about four hours is worth a look, though by then the watchdog will usually have said something first.

The canary, and why silence is the report

Every two hours, on Cloudflare cron 0 */2 * * *, our watchdog sends a probe from canary@ the domain to canary@ the same domain. It leaves our infrastructure completely, resolves our MX records, passes through Cloudflare Email Routing, hits the Worker, and runs the real parse-and-store path. At the top of ingest, after the domain resolves to a workspace, a subject-line check on __LD_CANARY__ records the receipt and absorbs the message, so no thread appears and no unread badge moves. The next scheduled run checks whether the previous probe arrived, and treats anything older than 60 minutes as lost rather than late. We wrote the full teardown, including the incident that caused it, in the canary that guards our inbound email.

It belongs in a warm-up log for one reason. Warm-up is reputation management on the way out, and it is the loudest, most-instrumented half of an email system. The receiving half fails in total silence: a deleted routing rule, an expired DNS change, an MX outage. None of those produce an error anywhere you are looking, and all of them look exactly like a quiet week.

So the watchdog emails the owner only when something is wrong, at most once every six hours, and clears that cooldown the instant things recover so the next real problem alerts immediately. No daily green report, on purpose. A status email you receive every morning is a status email you filter into a folder by Thursday, and a filtered alert system is not an alert system. Silence means healthy, and it is the only design where the absence of a message still carries information.

Two things we have not settled

DMARC is at p=quarantine, and reject is the goal. The progression is p=none to read reports, then quarantine, then reject once nothing legitimate is failing. We are at the middle step, and the gate to the last one is not a calendar date: it is reading the aggregate XML reports that arrive from noreply-dmarc-support@google.com and confirming that every source failing alignment is mail we do not care about. Moving to reject with a forgotten sender still in the wild means that sender's mail stops arriving, permanently, with no bounce anyone will notice. The audit script prints the current policy on every run and nags us toward reject. We will move when the reports are clean and not before.

Stream separation is deliberately deferred. The standard advice, which is in our own deliverability handbook, is to send bulk newsletters from a marketing subdomain and keep replies and transactional mail on the root, so a newsletter dip cannot sink a password reset. We are not doing it yet, and the reason is arithmetic. Splitting a small stream into two smaller streams gives you two domains with thin histories instead of one with a slightly thicker one, and consistency of volume is itself a reputation signal. Consolidating warms better than splitting at our size. The audit script auto-flags the split the moment active subscribers reach 20, which is our marker for "there is now enough volume to divide." The caveat we teach with it: a subdomain is isolation, not a wall, because sustained abuse rolls up to the parent domain regardless.

What this log is not

This is not a victory lap. We run a single live workspace, with real recipients, at dozens of sends a day. The ramp table is theory for us today, the auto-pause gates are twitchy at our sample size, and one of our constants has a seam in it. What is genuinely running is the enforcement: the cap is computed before every campaign, the two thresholds pause sending with no human in the loop, the suppression list is checked before every individual send, and a probe email proves the receiving pipe is open twelve times a day.

If someone is selling you a warm-up subscription, ask them a single question: which of your numbers does it change, and which of your recipients does it change. The honest answer is the first one only. That is the entire product.