# Automated warm-up schedule with daily volume ramp

- **Pattern:** `ab-000930` (`deliverability-engineering.warmup-reputation.automated-warmup-schedule`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000930
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000930)

## 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:

```ts
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:

  ```ts
  // 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 reliability.availability

Taxons: operational-readiness, cost-efficiency

HTML version: https://auditbuffet.com/patterns/ab-000930
