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.
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.
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.
-- 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);
// 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.
ID: email-sms-compliance.content-delivery.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:
-- 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);
// 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))