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.
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.
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.
ID: deliverability-engineering.warmup-reputation.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
}