Minimum spacing prevents back-to-back emails within a single send pair; frequency capping prevents runaway volume over a rolling window. A contact enrolled in four simultaneous sequences, each with a 24-hour minimum spacing, can still receive four emails per day — 28 per week — without violating any per-pair spacing rule. CWE-770 (Allocation of Resources Without Limits) applies at the application layer: the system places no global bound on how many times it contacts a single recipient. Under CAN-SPAM Act Sec. 5 and GDPR Art. 6, the volume of commercial email must reflect the reasonable expectations of the subscriber — not the maximum throughput the queue can sustain.
High because without a cross-campaign cap, contacts enrolled in multiple sequences receive unlimited email volume — a CAN-SPAM and GDPR Art-6 compliance risk at scale.
Enforce a rolling-window frequency cap per contact at send time, counting sends across all campaigns. When the cap is reached, defer (do not drop) the send until the oldest send in the window ages out.
const MAX_EMAILS_PER_WEEK = 5
async function checkFrequencyCap(contactId: string): Promise<{ allowed: boolean; resetsAt?: Date }> {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const recentCount = await db.emailSend.count({
where: { contactId, sentAt: { gte: sevenDaysAgo } }
})
if (recentCount >= MAX_EMAILS_PER_WEEK) {
const oldest = await db.emailSend.findFirst({
where: { contactId, sentAt: { gte: sevenDaysAgo } },
orderBy: { sentAt: 'asc' }
})
return { allowed: false, resetsAt: new Date(oldest!.sentAt.getTime() + 7 * 24 * 60 * 60 * 1000) }
}
return { allowed: true }
}
Call checkFrequencyCap before every sequence send — not only within a single sequence handler.
ID: campaign-orchestration-sequencing.cadence-spacing.frequency-cap
Severity: high
What to look for: Check whether a global frequency cap is enforced per contact — a maximum number of emails allowed within a rolling time window (e.g., no more than 3 emails per week per contact), regardless of how many campaigns or sequences the contact is enrolled in. This is different from minimum spacing (which is about the gap between consecutive emails) — frequency cap is about the total volume over a period. Look for: a query that counts emails sent to a contact in the last N days before allowing a new send, or a Redis counter with a TTL.
Pass criteria: A maximum emails-per-period cap is enforced per contact across all campaigns — no more than 5 emails per 7-day window is a reasonable default. When the cap is reached, new sends are deferred (not dropped) until the window resets. Quote the actual cap value configured in code. Count the enforcement points in the send path.
Fail criteria: No cross-campaign frequency cap. A contact enrolled in 4 simultaneous sequences could receive one email per day from each with no global limit. A cap that only applies within a single sequence does not count as pass.
Skip (N/A) when: The project sends only transactional emails where frequency caps are not applicable.
Detail on fail: "No frequency cap — a contact enrolled in multiple sequences can receive unlimited emails per week" or "Frequency cap only enforced within a single sequence, not across all campaigns"
Remediation: Enforce a cross-campaign frequency cap at send time:
const MAX_EMAILS_PER_WEEK = 5
async function checkFrequencyCap(contactId: string): Promise<{ allowed: boolean; resetsAt?: Date }> {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const recentCount = await db.emailSend.count({
where: { contactId, sentAt: { gte: sevenDaysAgo } }
})
if (recentCount >= MAX_EMAILS_PER_WEEK) {
const oldest = await db.emailSend.findFirst({
where: { contactId, sentAt: { gte: sevenDaysAgo } },
orderBy: { sentAt: 'asc' }
})
return { allowed: false, resetsAt: new Date(oldest.sentAt.getTime() + 7 * 24 * 60 * 60 * 1000) }
}
return { allowed: true }
}