A suppression list missing any of its four required sources — unsubscribes, hard bounces, spam complaints, or manual admin adds — has gaps that will cause sends to addresses that should never receive email. CAN-SPAM § 5 mandates honoring opt-outs (unsubscribes); GDPR Art. 21 covers the right to object (maps to unsubscribes and complaints); GDPR Art. 17 covers erasure (maps to manual admin removes); CASL § 6 mirrors CAN-SPAM's unsubscribe requirement. Spam complaints are the most commonly missing source: ESPs surface them via feedback loops that must be explicitly wired to write to the suppression table — they do not suppress automatically unless configured.
Medium because each missing suppression source creates a specific class of non-compliance (unsubscribes → CAN-SPAM; complaints → GDPR Art. 21) rather than a blanket deliverability failure, but the legal exposure is real.
Centralize all four suppression sources through a single addToSuppression function so no source can bypass the global table:
type SuppressionReason = 'unsubscribe' | 'hard_bounce' | 'spam_complaint' | 'manual_add'
async function addToSuppression(email: string, reason: SuppressionReason, addedBy = 'system') {
await db.suppression.upsert({
where: { email: email.toLowerCase().trim() },
create: { email: email.toLowerCase().trim(), reason, added_by: addedBy, suppressed_at: new Date() },
update: { reason, updated_at: new Date() }
})
}
Wire this to: your POST /api/unsubscribe endpoint, your bounce webhook handler (hard bounces only — see ab-000870), your ESP complaint feedback-loop webhook, and your admin contact management UI. Verify all four by querying SELECT DISTINCT reason FROM suppressions — all four values must appear.
ID: data-quality-list-hygiene.suppression-bounce.suppression-sources
Severity: medium
What to look for: Enumerate all code paths that write to the suppression table. Count and classify each by source type. The suppression list must include all of: (1) unsubscribes, (2) hard bounces, (3) spam complaints, and (4) manual admin adds. Check whether each of these 4 sources has a code path that writes to the suppression table with an appropriate reason code. Report the ratio: "N of 4 required sources present."
Pass criteria: At least 4 of 4 required sources write to the suppression table. The suppression table has records with reason values covering unsubscribe, hard_bounce, spam_complaint, and manual_add. All four sources write to the same table.
Fail criteria: Fewer than 4 of the required sources are present. For example: spam complaints are logged in an analytics table but never added to the suppression list, or manual admin adds bypass the suppression table.
Skip (N/A) when: Never — all four sources are universally required.
Detail on fail: List the missing sources. Example: "Suppression table only populated from unsubscribes and hard bounces — spam complaints from ESP not written to suppression list" or "Manual admin suppression goes to a separate blocklist table, not the global suppression table"
Remediation: Ensure all four sources write to the same suppression table:
type SuppressionReason = 'unsubscribe' | 'hard_bounce' | 'spam_complaint' | 'manual_add'
async function addToSuppression(email: string, reason: SuppressionReason, addedBy?: string) {
await db.suppression.upsert({
where: { email: email.toLowerCase().trim() },
create: {
email: email.toLowerCase().trim(),
reason,
added_by: addedBy ?? 'system',
suppressed_at: new Date()
},
update: {
// If already suppressed for a less serious reason, escalate
reason,
updated_at: new Date()
}
})
}
Wire this function into: your unsubscribe endpoint, your bounce webhook handler (hard bounces only), your complaint webhook handler, and your admin tools.