# Suppression list maintained; unsubscribed users never emailed

- **Pattern:** `ab-001255` (`email-sms-compliance.content-delivery.suppression-list`)
- **Severity:** info
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001255
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001255)

## Why it matters

CAN-SPAM §5(a)(4), GDPR Art. 21, and CCPA §1798.120 all require that opt-out requests result in permanent cessation of marketing communications. Tracking opt-out status only as a boolean on the user record — rather than in a dedicated suppression table — creates a structural fragility: account reactivation, user import, or a provider migration can silently re-subscribe opted-out addresses. A dedicated suppression table that is append-only and provider-agnostic survives provider changes, list exports, and bulk data operations without losing the opt-out record.

## Severity rationale

Info because the gap only causes harm if a specific data operation (provider migration, import, account reactivation) coincides with the missing suppression table — but that event, when it occurs, re-subscribes all opted-out users simultaneously.

## Remediation

Maintain a dedicated, append-only `email_suppressions` table in your primary database and filter every campaign send against it — never rely solely on the provider's suppression list.

```sql
-- Append-only suppression table — never delete rows
CREATE TABLE email_suppressions (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email      TEXT NOT NULL,
  reason     TEXT NOT NULL CHECK (reason IN ('unsubscribe', 'bounce', 'complaint', 'manual')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (email)
);
CREATE INDEX idx_email_suppressions_email ON email_suppressions (email);
```

```ts
// Filter every bulk send against the suppression table
const suppressed = new Set(
  (await db.$queryRaw<{ email: string }[]>`SELECT email FROM email_suppressions`)
    .map(r => r.email)
)
const eligible = recipients.filter(r => !suppressed.has(r.email))
```

Sync provider-side bounces and complaints back into this table (via webhook) so suppression is complete regardless of the send path.

## Detection

- **ID:** `suppression-list`
- **Severity:** `info`
- **What to look for:** Enumerate every relevant item. A suppression list is the mechanism that ensures opted-out users are never emailed again. It may live in your application database (an `email_suppressions` table or a `marketingOptOut` flag on subscribers), in your email service provider's suppression list (SendGrid and Mailchimp maintain suppression lists automatically for unsubscribes), or both. Check whether the application maintains its own suppression list in addition to relying on the email provider's list. If the provider's list is the only suppression mechanism, check what happens if you switch providers or export a list for a one-off campaign — opted-out addresses would not be suppressed. Check whether bulk campaign sends query the suppression list before sending, or whether the email provider's suppression is relied upon entirely.
- **Pass criteria:** At least 1 of the following conditions is met. A suppression list is maintained in the application database (not only in the email provider). Every marketing send — including any bulk campaign, import, or programmatic send — checks the application's suppression list before adding a recipient. The suppression list is never overwritten by imports.
- **Fail criteria:** No application-level suppression list. Opted-out addresses tracked only as a flag on the user record rather than in a dedicated suppression table (making it easier to accidentally re-subscribe on account reactivation). Suppression entirely delegated to the email provider with no local copy.
- **Skip (N/A) when:** The application sends no marketing email.
- **Detail on fail:** Example: `"Email opt-out tracked only via marketingOptOut boolean on users table. No dedicated suppression table. If provider is changed or a list is exported, opted-out addresses cannot be filtered."`.
- **Remediation:** Maintain a dedicated suppression table that is provider-agnostic:

  ```sql
  -- Dedicated suppression table — append-only, never deleted
  CREATE TABLE email_suppressions (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email      TEXT NOT NULL,
    reason     TEXT NOT NULL CHECK (reason IN ('unsubscribe', 'bounce', 'complaint', 'manual')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (email)
  );

  -- Index for fast lookups during bulk sends
  CREATE INDEX idx_email_suppressions_email ON email_suppressions (email);
  ```

  ```ts
  // Before any bulk email send, filter against suppression table
  const suppressed = await db.$queryRaw<{ email: string }[]>`
    SELECT email FROM email_suppressions
  `
  const suppressedSet = new Set(suppressed.map(r => r.email))
  const eligible = recipients.filter(r => !suppressedSet.has(r.email))
  ```

---

## External references

- external CAN-SPAM-§5(a)(4) — https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business
- gdpr Art. 21
- ccpa §1798.120

Taxons: privacy-consent, data-integrity

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