# Suppression list includes all required sources

- **Pattern:** `ab-000873` (`data-quality-list-hygiene.suppression-bounce.suppression-sources`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000873
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000873)

## Why it matters

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.

## Severity rationale

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.

## Remediation

Centralize all four suppression sources through a single `addToSuppression` function so no source can bypass the global table:

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

## Detection

- **ID:** `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:

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

## External references

- external CAN-SPAM-Sec5 — https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business
- gdpr Art. 17
- gdpr Art. 21
- external CASL-S6 — https://crtc.gc.ca/eng/internet/anti.htm
- iso-25010 functional-correctness

Taxons: data-integrity, privacy-consent, regulatory-conformance

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