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.
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.
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.
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.
ID: campaign-orchestration-sequencing.cadence-spacing.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:
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() } })
}