# Minimum spacing enforced between emails to same contact

- **Pattern:** `ab-000604` (`campaign-orchestration-sequencing.cadence-spacing.minimum-spacing`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000604
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000604)

## Why it matters

Without a minimum-spacing guard enforced at send time, a contact enrolled in two simultaneous sequences can receive back-to-back emails minutes apart. CAN-SPAM Act Sec. 5 does not set a specific inter-email minimum, but sending frequency that a recipient experiences as abusive constitutes a material compliance risk under GDPR Art. 6 (lawful basis) and ePrivacy Art. 13, both of which require that commercial communications reflect the consent and reasonable expectations of the recipient. Beyond regulatory exposure, rapid-fire emails generate spam complaints, damage sender reputation, and suppress deliverability across the entire sending domain — affecting all customers, not just the one who complained.

## Severity rationale

Critical because absent send-time spacing enforcement, overlapping sequences produce back-to-back emails that trigger spam complaints, degrade sender reputation, and create GDPR Art-6 exposure.

## Remediation

Add a `last_email_sent_at` timestamp to the contact record and check it in the send job handler — not at enrollment time. Defer rather than drop emails that arrive before the minimum gap.

```ts
async function sendSequenceStep(enrollmentId: string, stepId: string) {
  const enrollment = await db.contactSequence.findUnique({
    where: { id: enrollmentId }, include: { contact: true }
  })
  const contact = enrollment.contact
  const MIN_SPACING_HOURS = 4

  if (contact.lastEmailSentAt) {
    const hoursSinceLast = (Date.now() - contact.lastEmailSentAt.getTime()) / (1000 * 60 * 60)
    if (hoursSinceLast < MIN_SPACING_HOURS) {
      const delayMs = (MIN_SPACING_HOURS - hoursSinceLast) * 60 * 60 * 1000
      await queue.add('send-sequence-step', { enrollmentId, stepId }, { delay: delayMs })
      return // Deferred — do not send now
    }
  }

  await sendEmail(contact, step)
  await db.contact.update({ where: { id: contact.id }, data: { lastEmailSentAt: new Date() } })
}
```

The guard must live in the job handler, not the enrollment path. Enrollment-time checks cannot account for emails sent by other sequences after enrollment.

## Detection

- **ID:** `minimum-spacing`
- **Severity:** `critical`
- **What to look for:** Check whether the system enforces a minimum time gap between any two emails sent to the same contact — across all sequences and campaigns, not just within a single sequence. This guard must run at send time (in the job handler), not just at enrollment time. Look for: a query that checks the contact's most recent send timestamp before allowing a new send, a Redis key or database field tracking `last_sent_at` per contact, or a pre-send validation step that returns a delay if the contact received an email too recently.
- **Pass criteria:** Before sending any email, the system checks the contact's last send timestamp. If the gap is below the configured minimum (typically at least 4 hours), the send is deferred rather than dropped. Count the number of send-time guard checks and report the count even on pass.
- **Fail criteria:** No minimum spacing check at send time. Contacts enrolled in multiple simultaneous sequences could receive back-to-back emails with no enforced gap. A spacing check that runs only at enrollment time but not at actual send time does not count as pass.
- **Skip (N/A) when:** The project sends only transactional emails (receipts, alerts) where minimum spacing does not apply.
- **Cross-reference:** The Compliance & Consent Engine Audit checks opt-out processing timelines, which interact with spacing enforcement when contacts unsubscribe between scheduled sends.
- **Detail on fail:** `"No last_sent_at check in send handler — overlapping sequences can deliver consecutive emails minutes apart"` or `"Spacing only enforced at enrollment time, not at actual send time in job handler"`
- **Remediation:** Add a spacing guard in the send job handler:

  ```ts
  async function sendSequenceStep(enrollmentId: string, stepId: string) {
    const enrollment = await db.contactSequence.findUnique({ where: { id: enrollmentId }, include: { contact: true } })
    const contact = enrollment.contact

    const MIN_SPACING_HOURS = 4
    const lastSent = contact.lastEmailSentAt
    if (lastSent) {
      const hoursSinceLast = (Date.now() - lastSent.getTime()) / (1000 * 60 * 60)
      if (hoursSinceLast < MIN_SPACING_HOURS) {
        const delayMs = (MIN_SPACING_HOURS - hoursSinceLast) * 60 * 60 * 1000
        await queue.add('send-sequence-step', { enrollmentId, stepId }, { delay: delayMs })
        return // Defer, do not send now
      }
    }

    await sendEmail(contact, step)
    await db.contact.update({ where: { id: contact.id }, data: { lastEmailSentAt: new Date() } })
  }
  ```

## External references

- external CAN-SPAM-Act-2003-Sec5 — https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business
- gdpr Art. 6
- eprivacy Art. 13

Taxons: data-integrity, privacy-consent

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