Automated warm-up schedule with daily volume ramp
Why it matters
Sending at full volume from a new IP or domain on day one is the single most common cause of catastrophic deliverability failure for email platforms. ISPs track the ratio of wanted-to-unwanted email per IP; a new IP sending 100,000 messages before establishing reputation triggers spam classification at Gmail, Yahoo, and Outlook simultaneously, often with blocks that take weeks to reverse. The industry-standard remediation is a 2–4 week progressive warm-up starting at dozens of sends per day and doubling every few days — enforced by code, not a spreadsheet. Without automated enforcement, a scheduled job or a marketing push will bypass any manual volume intention.
Severity rationale
High because launching at full sending volume from a new IP or domain routinely causes permanent reputation damage that requires weeks of suppressed sending to recover from, blocking all outbound email during the recovery period.
Remediation
Implement a warm-up schedule as code that gates the daily send limit based on days since IP activation. The schedule must be read by the queue worker before dequeueing, not just documented:
const WARMUP_SCHEDULE = [
{ day: 1, maxVolume: 50 },
{ day: 2, maxVolume: 100 },
{ day: 3, maxVolume: 200 },
{ day: 7, maxVolume: 2_000 },
{ day: 14, maxVolume: 10_000 },
{ day: 28, maxVolume: 50_000 },
]
export function getWarmupLimit(ipStartDate: Date): number {
const days = Math.floor((Date.now() - ipStartDate.getTime()) / 86_400_000)
const tier = [...WARMUP_SCHEDULE].reverse().find(t => t.day <= days + 1)
return tier?.maxVolume ?? Infinity
}
Store warmupStartDate on each SendingIp record. Before dequeueing a batch, query sentToday and abort if it would exceed getWarmupLimit(ip.warmupStartDate). Skip the warmup gating only for ESPs (SendGrid shared IPs, etc.) where the provider manages reputation on your behalf.
Detection
-
ID:
automated-warmup-schedule -
Severity:
high -
What to look for: Count all warm-up schedule definitions and enumerate the daily volume tiers. Search for warm-up logic: functions or config objects that define a progressive sending schedule (e.g., Day 1: 50 emails, Day 2: 100, Day 3: 200...). Look for files named
warmup.ts,warm-up.ts,sendingSchedule.ts,volumeRamp.ts, or similar. Check queue concurrency or rate limiter configs that are progressively updated based on account age or warm-up day. A warm-up schedule should span 2–4 weeks for a new IP/domain and increase volume by roughly 2x per day or per few days. -
Pass criteria: At least 1 warm-up schedule exists as code (not just as a spreadsheet or manual process) with at least 5 volume tiers. It defines daily or per-period maximum sending volumes that increase over a 2–4 week period. The schedule is enforced automatically — the system queries the warm-up schedule to determine current limits before sending.
-
Fail criteria: No warm-up schedule exists in code. Sending starts at full volume on a new IP or domain. Warm-up is described only in documentation with no automated enforcement.
-
Skip (N/A) when: The project uses a shared IP pool from an ESP (e.g., SendGrid Shared IPs) where warm-up is managed by the provider, or the system has been sending from established IPs for more than 6 months with stable reputation.
-
Detail on fail:
"No warm-up schedule found — new IPs and domains send at full volume from day one, risking reputation damage"or"Warm-up described in docs but not enforced in code — manual process with no automated volume capping" -
Remediation: Implement a warm-up schedule that gates daily sending volume:
// Warm-up schedule: [maxDailyVolume, cumulative days since start] const WARMUP_SCHEDULE: Array<{ day: number; maxVolume: number }> = [ { day: 1, maxVolume: 50 }, { day: 2, maxVolume: 100 }, { day: 3, maxVolume: 200 }, { day: 4, maxVolume: 400 }, { day: 5, maxVolume: 700 }, { day: 6, maxVolume: 1_000 }, { day: 7, maxVolume: 2_000 }, { day: 10, maxVolume: 5_000 }, { day: 14, maxVolume: 10_000 }, { day: 21, maxVolume: 25_000 }, { day: 28, maxVolume: 50_000 }, // Beyond day 28: full volume ] export function getWarmupLimit(ipStartDate: Date): number { const daysSinceStart = Math.floor( (Date.now() - ipStartDate.getTime()) / (1000 * 60 * 60 * 24) ) // Find the applicable schedule tier const tier = [...WARMUP_SCHEDULE] .reverse() .find(t => t.day <= daysSinceStart + 1) return tier?.maxVolume ?? Infinity // Past warm-up period } // In your sending job, check before dequeuing: export async function canSendMore(ipId: string): Promise<boolean> { const ip = await db.sendingIp.findUnique({ where: { id: ipId } }) const limit = getWarmupLimit(ip.warmupStartDate) const sentToday = await db.sendLog.count({ where: { ipId, sentAt: { gte: startOfDay(new Date()) } } }) return sentToday < limit }
External references
- iso-25010:2011 · reliability.availability — ISO 25010 Availability sub-characteristic
Taxons
History
- 2026-04-18·v1.0.0·Initial import from deliverability-engineering·automated