Blazalek.com

04 / 10

List Management

Keeping lists clean: suppression lists, automated hygiene, data retention, and separating transactional from marketing streams so you don't erode sender reputation.

By

Key takeaways

  • Suppress hard bounces and complaints immediately and permanently, re-mailing them is the fastest way to lose reputation.
  • Automate hygiene instead of relying on manual cleanup.
  • Separate transactional and marketing streams so a list problem stays contained.
On this page

This chapter answers how to keep your recipient lists clean so you only ever send to people who can and should receive your mail. It is the operational counterpart to Deliverability: authentication and reputation get you into the inbox, but a dirty list quietly erodes both over time. Every dead address you keep mailing, every complaint you ignore, every inactive subscriber you cling to drags your sender reputation down.

The core principle: it is always cheaper to not send a bad email than to send it and deal with the consequences. Suppression and hygiene are how you systematically avoid sending bad email.

This is not an optional refinement. Since February 2024, the Gmail, Yahoo, and Microsoft bulk-sender requirements make list hygiene a hard gate: any domain sending roughly 5,000+ messages per day to those mailboxes must keep its user-reported spam-complaint rate below 0.3% (and is expected to stay nearer 0.1%), authenticate with SPF, DKIM, and DMARC, and offer RFC 8058 one-click unsubscribe on marketing mail. A list full of dead and disengaged addresses is the single fastest way to breach the 0.3% complaint ceiling, because complaints are computed against delivered volume, and dead addresses inflate the denominator only until they bounce, then the disengaged remainder drives the rate up. List management is therefore a precondition for inbox placement at the largest mailbox providers, not a nicety.

Quick answers to the questions in this chapter:

  • What is a suppression list and why do I need one?
  • Which addresses should I suppress, and which can I ever bring back?
  • How do I check suppression before every send?
  • What is list hygiene and how often should I run each cleanup task?
  • Should I delete inactive subscribers or try to win them back first?
  • How long should I keep each kind of email data?
  • Which metrics tell me my list is going bad?
  • Should transactional and marketing lists be separate?

What is a suppression list and why do I need one?

A suppression list is a permanent "never send here" registry. It prevents sending to addresses that should never receive email, such as those that hard bounced or filed a spam complaint.

Think of it as a guard rail that sits in front of every send. Before any message goes out, you check the recipient against this list. If they are on it, you do not send. This single check is the most important deliverability safeguard you can build, because the fastest way to wreck a sender reputation is to keep mailing addresses that bounce or complain.

A suppression list is different from an unsubscribe list or a marketing audience. It is a low-level safety layer that applies to your whole sending system, including transactional mail for the most damaging signals (hard bounces and complaints).

Provider suppression versus your own suppression list

Most email providers (Amazon SES, Postmark, SendGrid, Mailgun, Resend, Brevo, SparkPost, Mandrill) maintain a suppression list of their own and will silently drop sends to addresses on it. That is not a substitute for keeping your own list. There are three reasons you must own the data:

  • Portability. Provider suppression lives inside the provider. If you migrate ESPs, a routine event during a deliverability incident or a pricing renegotiation, you start the new provider with an empty suppression list and immediately re-mail every hard bounce and complainer you ever had. Owning the data means a migration is an export/import, not a reputation reset.
  • Cross-provider consistency. Mature senders split traffic across multiple providers (for example, transactional on one, marketing on another, or a primary and a failover). A bounce observed on provider A must suppress on provider B too. Only a central store you control can enforce that.
  • Visibility and analytics. Provider dashboards show their view. Your own table lets you join suppression events to campaigns, segments, and import batches so you can answer "which send caused this spike?", see source_email_id below.

Treat the provider's suppression list as a backstop and a source of truth for bounce/complaint events (delivered via webhooks; see Webhooks & Events), and your own database as the authoritative gate that every send passes through.

Which addresses should I suppress, and which can I ever bring back?

Suppress hard bounces and complaints immediately and never bring them back. Suppress soft bounces after 3 failures and manual removals on request, and those two can be reversed under controlled conditions.

ReasonActionCan Unsuppress?
Hard bounceAdd immediatelyNo (address invalid)
Complaint (spam)Add immediatelyNo (legal requirement)
Soft bounce (3x)Add after thresholdYes, after 30 to 90 days
Manual removalAdd on requestOnly if user requests

Why each rule works the way it does:

  • Hard bounce, add immediately, never unsuppress. The address is permanently invalid. Re-sending can never succeed and only signals poor list hygiene to providers.
  • Complaint, add immediately, never unsuppress. The recipient explicitly said "this is spam." Continuing to mail them is both a reputation killer and, in many jurisdictions, a legal violation. Honor it permanently.
  • Soft bounce (3x), add after threshold, can unsuppress after 30 to 90 days. A single soft bounce is transient (full mailbox, server down). After 3 failures it behaves like a permanent problem, so suppress. Because the cause might have been temporary, the address can be re-tried after a cooling-off period.
  • Manual removal, add on request, unsuppress only if the user requests. The user asked to be removed. Respect their choice. Only re-add them if they themselves opt back in.

The crucial distinction is reversibility. Hard bounces and complaints are permanent and must never be silently re-activated. Soft-bounce and manual suppressions can be lifted, but only under controlled conditions.

What counts as a hard bounce versus a soft bounce?

A hard bounce is a permanent failure: the mailbox does not exist, the domain does not exist, or the server permanently rejected the message (SMTP 5.x.x codes). A soft bounce is a temporary failure: the mailbox is full, the server is down, or the message was greylisted (often SMTP 4.x.x codes). Treat hard bounces as final and soft bounces as retryable until they cross your 3-failure threshold.

The SMTP reply code and its enhanced status code (RFC 3463, the class.subject.detail triplet like 5.1.1) are how you classify a bounce mechanically. The first digit is the verdict; the enhanced code tells you why.

Enhanced codeMeaningClassify as
5.1.1Bad destination mailbox address (no such user)Hard bounce
5.1.2Bad destination system / domain does not existHard bounce
5.1.10Recipient address rejected (Microsoft "does not exist")Hard bounce
5.2.1Mailbox disabled / not accepting messagesHard bounce
5.7.1Delivery not authorized / blocked by policyBlock (see below)
5.7.26Multiple authentication failures (SPF/DKIM/DMARC)Block, fix auth, not the address
4.2.2Mailbox full / over quotaSoft bounce
4.3.0 / 4.3.2System not accepting messages / temporary outageSoft bounce
4.4.1No answer from host / connection timeoutSoft bounce
4.7.xTemporary policy / rate-limit / greylistingSoft bounce, retry with backoff

Two important edge cases the simple hard/soft split misses:

  • Block bounces are not address problems. A 5.7.1 or a "blocked due to reputation / on a blocklist" rejection is the receiving server refusing you, not telling you the address is dead. Suppressing the address is the wrong response, you would be discarding good recipients to hide a reputation or authentication problem. Treat block bounces as a deliverability signal (see Deliverability) and a 5.7.26 specifically as an authentication failure to fix (SPF per RFC 7208, DKIM per RFC 6376, DMARC per RFC 7489), not as a suppression event.
  • Asynchronous bounces. Many servers accept the message at SMTP time (a 250) and bounce it later via a Delivery Status Notification (a "backscatter" or DSN email to your return-path). Your provider surfaces these as bounce webhooks minutes to hours after the send. Your classification logic must run on the webhook event, not only on the synchronous SMTP response, or you will miss most real bounces.

Do not pattern-match on free-text bounce strings if you can avoid it; they are wildly inconsistent across providers. Prefer the provider's normalized bounce_type/bounce_subtype plus the enhanced status code, and fall back to free text only for ambiguous cases.

How do I store suppression entries in my database?

What a suppression entry stores:

FieldWhy it's there
email (normalized, lowercased)The match key at send time.
reason (enum)hard_bounce, complaint, unsubscribe, soft_bounce, manual, governs reversibility.
created_atWhen the suppression happened (hygiene, spike analysis).
source_email_idWhich message or campaign triggered it.

Store one row per address with the reason it was suppressed, the time it happened, and which email triggered it. Recording the reason is what lets you apply the reversibility rules above.

Code for your engineers (TypeScript)
// Suppression list schema
interface SuppressionEntry {
  email: string;
  reason: 'hard_bounce' | 'complaint' | 'unsubscribe' | 'soft_bounce' | 'manual';
  created_at: Date;
  source_email_id?: string; // Which email triggered this
}

// Check before every send
async function canSendTo(email: string): Promise<boolean> {
  const suppressed = await db.suppressions.findOne({ email });
  return !suppressed;
}

// Add to suppression list
async function suppressEmail(email: string, reason: string, sourceId?: string) {
  await db.suppressions.upsert({
    email: email.toLowerCase(),
    reason,
    created_at: new Date(),
    source_email_id: sourceId,
  });
}

What to notice in this schema and why it matters:

  • reason is an enum, not free text. Recording why an address was suppressed is what lets you apply the reversibility rules. You can never unsuppress a hard_bounce or complaint, but you can revisit a soft_bounce. Storing the reason also powers analytics (is your list growing because of bounces or complaints?).
  • source_email_id records what triggered the suppression. When a spike appears in your suppression list, this field lets you trace it back to the specific campaign or message that caused it.
  • suppressEmail lowercases the address (email.toLowerCase()). Email local parts are case-insensitive in practice. Normalizing prevents User@x.com and user@x.com from being treated as two different people, which would let suppressed addresses slip through.
  • upsert is idempotent. Suppressing an already-suppressed address is a no-op rather than an error, which matters because the same bounce or complaint event may be delivered more than once via webhooks (see Webhooks & Events).
  • canSendTo returns a boolean and is meant to be cheap. It runs before every send, so the suppression store should be indexed on email for fast lookups.

Normalizing addresses correctly (and what not to normalize)

Lowercasing is necessary but not sufficient, and over-normalizing is its own bug. The standards-correct rules:

  • The domain is case-insensitive and should be lowercased. Example.COM and example.com are the same host.
  • The local part is, per RFC 5321, technically case-sensitive, but in practice every major provider treats it case-insensitively, and treating it as case-sensitive lets a suppressed User@x.com slip through as user@x.com. Lowercasing the whole address is the pragmatic, safe choice for a suppression gate.
  • Do not strip dots or +tags for suppression. Gmail ignores dots and treats + as a sub-address, so j.doe+news@gmail.com reaches the same inbox as jdoe@gmail.com. It is tempting to "canonicalize" these. Do not do it on the suppression path: provider rules differ (this is Gmail-specific behavior), the suppression key must match exactly what you will pass to the provider's to: field, and aggressive canonicalization will suppress innocent third parties who legitimately share a base address. Canonicalization belongs in signup deduplication (see Email Capture), not in the send-time gate.
  • Trim whitespace and reject control characters before storing, a trailing space or a \r\n is a classic source of "the suppression check passed but the send still went out."
Code for your engineers (TypeScript)
function normalizeEmail(raw: string): string {
  const trimmed = raw.trim();
  const at = trimmed.lastIndexOf('@');
  if (at <= 0 || at === trimmed.length - 1) {
    throw new Error(`Invalid email: ${raw}`);
  }
  const local = trimmed.slice(0, at);
  const domain = trimmed.slice(at + 1).toLowerCase();
  // Lowercase the whole address for matching; do NOT strip dots or +tags here.
  return `${local}@${domain}`.toLowerCase();
}

Schema, indexing, and scale

For anything past a few hundred thousand rows, a relational table with the right index is the simplest correct choice:

Show example
CREATE TABLE suppressions (
  email           TEXT        NOT NULL,
  reason          TEXT        NOT NULL CHECK (reason IN
                    ('hard_bounce','complaint','unsubscribe','soft_bounce','manual')),
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  source_email_id TEXT,
  PRIMARY KEY (email)
);

-- Fast point lookups on the send path are guaranteed by the PK.
-- Index reason + time for hygiene queries and spike analysis.
CREATE INDEX idx_suppressions_reason_created ON suppressions (reason, created_at);

Notes on scale and latency:

  • The send-path lookup must be a primary-key / unique-index hit, never a scan. At high send rates this query runs millions of times a day; an unindexed lookup will dominate your send latency and your database load.
  • For very high throughput, front the lookup with a cache. A Redis set or a Bloom filter loaded from the suppression table turns the common "not suppressed" case into an in-memory check. A Bloom filter is ideal because false positives are safe-ish to verify against the source of truth, and false negatives are impossible, so you never miss a suppression, you only occasionally double-check. Refresh it on every suppression write.
  • Suppression is append-mostly. Removals (unsuppression) are rare and should be audited (see below). Do not run destructive cleanup against this table.

Auditing unsuppression

Because soft-bounce and manual suppressions are reversible, the act of removing a row must leave a trail. Never hard-delete a suppression silently. Either soft-delete with a removed_at/removed_reason, or move the row to a suppression_history audit table. This protects you in two ways: it proves to an auditor that a complaint was never re-mailed, and it lets you reconstruct who decided to unsuppress an address and why if a reputation incident traces back to it.

How do I check suppression before every send?

Wrap your provider call in a single function that checks suppression first and returns early if the address is suppressed. Make that function the only path to the provider.

Code for your engineers (TypeScript)
async function sendEmail(to: string, emailData: EmailData) {
  if (!await canSendTo(to)) {
    console.log(`Skipping suppressed email: ${to}`);
    return { skipped: true, reason: 'suppressed' };
  }

  return await emailClient.send({ to, ...emailData });
}

Why this wrapper exists: suppression only works if every path to your email provider goes through this check. The pattern is to make sendEmail the single chokepoint. No code calls the provider SDK (emailClient.send) directly. If even one code path bypasses the check, suppressed addresses leak through and your reputation suffers.

Practical notes:

  • The function returns a structured result ({ skipped: true, reason: 'suppressed' }) rather than throwing. A suppressed recipient is an expected, normal outcome, not an error. Callers should be able to handle it without try/catch.
  • Logging skipped sends gives you visibility: a rising count of skips can reveal that you are repeatedly trying to mail addresses you have already suppressed (for example a stale segment).
  • This check applies to both transactional and marketing mail for hard bounces and complaints (see the transactional versus marketing section below).

Reason-aware gating: suppression is not always all-or-nothing

The naive canSendTo treats every suppression equally. That is correct for hard bounces and complaints, which suppress all streams, but it is too blunt for unsubscribe, which is marketing-only. A user who unsubscribed from your newsletter must still receive their password reset. Make the gate aware of both the suppression reason and the stream of the message being sent.

Code for your engineers (TypeScript)
type Stream = 'transactional' | 'marketing';

// reasons that block every stream, permanently and unconditionally
const HARD_BLOCK_REASONS = new Set(['hard_bounce', 'complaint']);

async function canSendTo(email: string, stream: Stream): Promise<boolean> {
  const entry = await db.suppressions.findOne({ email: normalizeEmail(email) });
  if (!entry) return true;

  // Hard bounces and complaints block everything, always.
  if (HARD_BLOCK_REASONS.has(entry.reason)) return false;

  // Unsubscribe and manual removals are marketing-only by default.
  if (stream === 'marketing') return false;

  // Transactional may still proceed for unsubscribe / soft_bounce / manual.
  return true;
}

This single function now encodes the whole cross-stream policy: hard bounces and complaints suppress everything; unsubscribe and manual removals stop marketing but let transactional through; a soft-bounce suppression (an undeliverable mailbox right now) blocks marketing but you may choose to still attempt a critical transactional message, since the alternative is the user never getting their receipt. Make the stream an explicit, required argument so no caller can "forget" to declare what kind of mail it is sending.

Bulk sends: filter, do not loop

For a campaign of 100,000 recipients, calling canSendTo once per address in a loop is both slow and races against concurrent suppression writes. Instead, resolve suppression as a set operation at send time, immediately before handing the batch to the provider:

Code for your engineers (TypeScript)
async function filterSuppressed(emails: string[], stream: Stream): Promise<string[]> {
  const normalized = emails.map(normalizeEmail);
  const suppressed = await db.suppressions.find({
    email: { $in: normalized },
    ...(stream === 'marketing'
      ? {} // marketing: any suppression reason blocks
      : { reason: { $in: ['hard_bounce', 'complaint'] } }), // transactional: only hard blocks
  });
  const blocked = new Set(suppressed.map((s) => s.email));
  return normalized.filter((e) => !blocked.has(e));
}

Resolve this as late as possible, ideally at the moment of send, not when the campaign is queued. A campaign queued at 9:00 and sent at 9:30 must respect every unsubscribe and complaint that arrived in that half hour. Re-checking at dispatch time is how you avoid "but it was already queued" being a real excuse.

What are common mistakes with suppression checks?

  • Bypassing the wrapper. A new feature calls the provider SDK directly "just this once," and suppressed addresses leak. Lint or grep for direct provider calls to catch this.
  • Case sensitivity. Storing addresses with mixed case but checking with the raw input. Always normalize to lowercase on both write and read.
  • Checking after sending. The check must run before the provider call, not in a webhook after delivery is already attempted.
  • No index on email. A full table scan on every send adds latency. Index the lookup column.
  • Checking at queue time only. Re-check at dispatch so suppressions added between queueing and sending are honored.
  • Treating block bounces as suppressions. A 5.7.1/reputation block is not a dead address. Suppressing it discards good recipients and hides the real problem.
  • Trusting the provider's list as your only copy. When you migrate providers, that list does not come with you. Keep your own.

What is list hygiene and how is it different from suppression?

List hygiene is the ongoing program of removing problem addresses promptly and pruning subscribers who have gone cold. Suppression handles the permanent "never send here again" cases. Hygiene is the broader, continuous maintenance around it.

The goal is a list that is small, engaged, and clean, which is far healthier for deliverability than a large list full of dead weight. A 10,000-address list where 9,000 people open is worth more than a 100,000-address list where 5,000 open.

The distinction in one line: suppression is binary and permanent (will I ever send here?); hygiene is graded and continuous (should I keep sending here?). Suppression protects you from the catastrophic signals, bounces and complaints. Hygiene protects you from the slow rot of disengagement, which is what actually pushes a domain over the 0.3% complaint threshold and into the spam folder.

Validation at capture is the cheapest hygiene there is

The cleanest list is one that never admitted a bad address. Front-loading validation at the point of capture removes the majority of future hard bounces before they ever cost you reputation:

  • Syntax + MX check. Reject addresses that fail RFC 5321 syntax or whose domain has no MX (or A) record. A domain with no mail exchanger cannot receive mail.
  • Disposable-domain blocking. Maintain or subscribe to a list of disposable/throwaway domains if your product warrants it.
  • Confirmed opt-in (double opt-in) for marketing. Requiring the subscriber to click a confirmation link proves the address is real, reachable, and consenting in one step. It is the single most effective hygiene measure for a marketing list.
  • Typo correction. Suggest gmail.com when a user types gmial.com. This recovers real subscribers who would otherwise hard-bounce.

The mechanics of doing this at the form live in Email Capture; the point here is that capture-time validation and run-time hygiene are two ends of the same program.

How often should I run each cleanup task?

Run the most damaging cleanups in real time, soft bounces daily, and inactive pruning monthly.

TaskFrequencyAction
Remove hard bouncesReal-time (via webhook)Immediate suppression
Remove complaintsReal-time (via webhook)Immediate suppression
Process unsubscribesReal-timeRemove from marketing lists
Review soft bouncesDailySuppress after 3 failures
Remove inactiveMonthlyRe-engagement, then remove

Learn more: the M3AAWG sender best practices (https://www.m3aawg.org/published-documents) and your provider's list-hygiene guidance.

Why the cadence differs per task:

  • Hard bounces and complaints, real-time. These are the most damaging signals, so the response must be instant. Wiring them to webhooks (see Webhooks & Events) means the moment a provider reports a bounce or complaint, the address is suppressed before you can ever mail it again.
  • Unsubscribes, real-time. Legally and ethically, an unsubscribe must take effect immediately. You cannot send one more campaign "because it was already queued." Process them the instant they arrive.
  • Soft bounces, daily. A single soft bounce is not actionable, so these are reviewed in a daily batch, suppressing only addresses that have crossed the 3-failure threshold.
  • Inactive subscribers, monthly. Disengagement is gradual, so monthly is frequent enough. Do not delete the inactive outright. Run them through re-engagement first.

Acting on complaints: the Feedback Loop (FBL)

The real-time complaint suppression above depends on actually receiving complaints. There are two channels, and you should wire both:

  • Provider complaint webhooks. Your ESP forwards spam complaints it receives on your behalf as complaint/spam events. This is the primary channel and the one your suppressEmail(..., 'complaint') call hangs off.
  • Mailbox-provider Feedback Loops. Several providers expose an FBL: when a user clicks "report spam," the mailbox provider sends an ARF (Abuse Reporting Format) report back to the sender. Microsoft's SNDS/JMRP and Yahoo's CFL are examples. If you operate your own sending infrastructure rather than relying entirely on an ESP, enroll in these FBLs; if you use an ESP, confirm it is enrolled on your behalf and is forwarding the reports to you.

Gmail is the notable exception: it does not offer a per-message FBL. Instead it exposes aggregate complaint and reputation data through Google Postmaster Tools, which is why monitoring Postmaster's complaint-rate graph is the only way to see your Gmail complaint rate against the 0.3% threshold. Set up Postmaster Tools for every sending domain.

One-click unsubscribe is part of hygiene, not just compliance

Since February 2024, marketing mail to Gmail/Yahoo/Microsoft must implement RFC 8058 one-click unsubscribe. This is a hygiene mechanism as much as a legal one: a frictionless unsubscribe is what keeps an annoyed recipient from clicking "report spam" instead, and a complaint is far more damaging than an unsubscribe. You must include both headers (the List-Unsubscribe URL/mailto from RFC 2369 and the RFC 8058 List-Unsubscribe-Post), because the POST token is what tells the mailbox provider the link can be actioned without a confirmation page:

List-Unsubscribe: <https://mail.example.com/u/8f2a...>, <mailto:unsubscribe@example.com?subject=unsub-8f2a>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

The mailbox provider sends POST with body List-Unsubscribe=One-Click to your URL. Your endpoint must process that within (the requirements say) two days, and must not require the user to log in or confirm. Wire the endpoint straight to suppressEmail(address, 'unsubscribe') for the marketing stream. The full mechanics, including the mailto fallback and signing the token, are in Marketing Emails and Compliance.

Should I delete inactive subscribers or try to win them back first?

Try to win them back first. Run inactive subscribers through a re-engagement campaign before removing them, so you do not throw away recoverable people.

Follow these four steps before removing inactive subscribers:

  1. Identify inactive: No opens or clicks in 45 to 90 days.
  2. Send re-engagement: "We miss you" or "Still interested?"
  3. Wait 14 to 30 days for a response.
  4. Remove non-responders from active lists.
Code for your engineers (TypeScript)
async function runReengagement() {
  const inactive = await getInactiveSubscribers(90); // 90 days

  for (const subscriber of inactive) {
    if (!subscriber.reengagement_sent) {
      await sendReengagementEmail(subscriber);
      await markReengagementSent(subscriber.email);
    } else if (daysSince(subscriber.reengagement_sent) > 30) {
      await removeFromMarketingLists(subscriber.email);
    }
  }
}

Why re-engage instead of just deleting: inactive subscribers hurt deliverability. They lower your engagement rate, and long-dormant addresses can turn into spam traps. But some "inactive" people are simply busy, not gone. A re-engagement campaign is a last, controlled attempt to win them back before you remove them, so you do not throw away recoverable subscribers.

How the code implements the four steps:

  • getInactiveSubscribers(90) selects everyone with no opens or clicks in the last 90 days (step 1).
  • For anyone who has not yet received a re-engagement email, it sends one and records that fact with markReengagementSent (step 2). Recording the timestamp is essential. It prevents re-sending and starts the response clock.
  • For anyone who did get the re-engagement email more than 30 days ago and still has not responded, removeFromMarketingLists prunes them (steps 3 and 4).

Practical notes:

  • The reengagement_sent flag or timestamp is the state machine: it distinguishes "not yet asked" from "asked, waiting" from "asked, time is up."
  • Removal is from marketing lists specifically. A re-engagement failure does not suppress transactional mail (see below).
  • Keep re-engagement subject lines honest and simple ("Still interested?"). This is not the place for aggressive tactics.

A caution about open-based engagement after Apple MPP

Since Apple Mail Privacy Protection (2021), Apple pre-fetches tracking pixels for users who enable it, registering an "open" whether or not the human read the message. This means open rate alone is no longer a reliable engagement signal, a meaningful fraction of your "opens" are machine-generated. The consequences for the logic above:

  • Prefer clicks over opens as the primary engagement signal. A click is a real human action MPP does not fake.
  • Use opens as a weak negative signal, not a positive one. "Never even registered an open in 90 days" is still meaningful (MPP would usually have generated one), so a complete absence of opens is a stronger inactivity signal than the presence of opens is an activity signal.
  • Blend in non-email signals where you have them: last login, last purchase, last in-app session. A subscriber who logs in weekly but never "opens" your email is engaged with your product, just not your mail, treat them differently from someone gone entirely dark.

Sunset policies: bounding engagement decay automatically

A re-engagement campaign is a one-off rescue. A sunset policy is the standing rule that automatically stops mailing addresses past a defined inactivity horizon, so disengagement can never accumulate unbounded. A typical tiered policy:

Inactivity windowAction
0–90 daysNormal cadence
90–180 daysReduce frequency; prefer your best-performing content
180 daysEnter re-engagement flow (one or two attempts)
Re-engagement failsMove to a "sunset" / paused state; stop marketing sends
12 months no activity at allRemove from the marketing list entirely

Tune the windows to your natural sending cadence, a daily-deal sender sunsets far faster than a quarterly-newsletter sender. The principle is constant: the longer someone ignores you, the more likely your mail to them is being filtered to spam, which drags down placement for everyone else on the list. Sunsetting is how you protect the engaged majority from the dead-weight minority.

What is a spam trap and why do dead addresses become one?

A spam trap is an address that providers and blocklist operators use to catch senders with poor list hygiene. A pristine trap was never a real user. A recycled trap is a real address that was abandoned, then reactivated by the provider purely to flag senders who keep mailing it. Long-dormant addresses on your list can quietly become recycled traps, which is exactly why you prune the inactive instead of mailing them forever.

The trap taxonomy and how each gets onto your list:

  • Pristine traps were never valid and never opted in. Hitting one means you acquired an address you were never given, purchased lists, scraped addresses, or a public form with no validation. Pristine hits are the most damaging because they prove non-consensual acquisition. The defense is never to buy or scrape, and to use confirmed opt-in.
  • Recycled traps were real users who abandoned the mailbox; after a long dormancy (often ~6–12 months of no logins) the provider repurposes the now-defunct address as a trap. These appear on lists you built legitimately, they are your former subscribers who went silent. The defense is exactly the sunset policy above: stop mailing the long-dormant before the provider recycles them.
  • Typo traps are misspellings of common domains (gmial.com, hotnail.com) that operators register. The defense is typo correction at capture (see Email Capture).

You cannot identify traps directly, by design, mailing one produces no bounce and no complaint, just silent reputation damage. That is why trap avoidance is structural: clean acquisition, confirmed opt-in, and aggressive sunsetting are the only controls, and there is no recovery step that removes a trap from your list because you do not know which address it is.

How long should I keep each kind of email data?

Keep operational data long enough to debug and report, keep legally required records for their mandated periods, and delete bulky or sensitive data as soon as it stops earning its keep.

Data TypeRecommended RetentionNotes
Send attempts90 daysDebugging, analytics
Delivery status90 daysCompliance, reporting
Bounce/complaint events3 yearsSuppression audit trail
Suppression listIndefiniteNever delete
Email content30 daysStorage costs
Consent records3 years after expiryLegal requirement

Why each retention period:

  • Send attempts and delivery status, 90 days. Long enough to debug delivery problems and produce meaningful analytics, but not so long that logs balloon. Most operational questions ("did this user get the email last week?") fall well within 90 days.
  • Bounce and complaint events, 3 years. These are the audit trail proving why an address is on your suppression list, and they back the deliverability reporting you may need to show a mailbox provider or auditor. (Note that the legal record-keeping obligation under laws like CASL is to retain proof of consent, see the consent-records row, not bounce/complaint events specifically.)
  • Suppression list, indefinite, never delete. This is the one store you keep forever. Deleting it would let permanently bad addresses (hard bounces, complaints) re-enter your sending and re-damage your reputation.
  • Email content, 30 days. Full message bodies are bulky and the most sensitive data you hold, so keep them only briefly. Long enough to investigate a recent issue, then purge to control storage costs and limit exposure.
  • Consent records, 3 years after expiry. Proof of consent is a legal requirement (see Compliance). Keep it well past the point the consent itself lapses, so you can demonstrate you had permission.

These are sensible defaults, not legal advice, your retention obligations vary by jurisdiction and by the legal basis you rely on. The retention table balances three competing pressures: operational need (you must be able to debug and report), legal obligation (some records you are required to keep, and required not to keep past necessity), and risk minimization (every byte of recipient data you hold is a byte that can leak). Email content sits at the high-risk end, it may contain PII, password-reset links, or invoice details, which is why it has the shortest retention. Suppression and consent sit at the must-keep end. Everything else is tuned in between.

How do I implement a retention policy in code?

Run a daily cleanup job that deletes each data category against its own cutoff and never touches suppressions or consent records.

Code for your engineers (TypeScript)
// Daily cleanup job
async function cleanupOldData() {
  const now = new Date();

  // Delete old email logs (keep 90 days)
  await db.emailLogs.deleteMany({
    created_at: { $lt: subDays(now, 90) }
  });

  // Delete old email content (keep 30 days)
  await db.emailContent.deleteMany({
    created_at: { $lt: subDays(now, 30) }
  });

  // Never delete: suppressions, consent records
}

What to notice and why it matters:

  • It is a daily job. Retention is enforced continuously, not in occasional manual sweeps. A daily cadence keeps storage bounded and ensures sensitive content never lingers far past its 30-day window.
  • Each category is deleted against its own cutoff. Logs use a 90-day cutoff (subDays(now, 90)); content uses 30 days. The cutoffs come straight from the retention table above.
  • The comment // Never delete: suppressions, consent records is load-bearing. It is a deliberate reminder that two stores are intentionally excluded from cleanup. Accidentally adding them here would be a serious bug: you would lose your permanent suppression protection and your legal consent proof.
  • Run it off-peak. Bulk deletes are I/O heavy. Schedule the job for a low-traffic window.

Making the retention job safe and observable

A bulk-delete job that runs daily and is "never supposed to touch" two tables is a foot-gun. Harden it:

  • Batch the deletes. A single deleteMany over months of accumulated rows can lock the table or blow up replication lag. Delete in bounded chunks (e.g. 10,000 rows per statement) in a loop until none remain.
  • Log counts, and alert on anomalies. Record how many rows each category deleted. A daily delete count that suddenly jumps by an order of magnitude usually means a bug in the cutoff math or a clock problem, page on it before it silently destroys data.
  • Prefer an allow-list of deletable tables over a deny-list. Rather than relying on a comment to remember what not to delete, structure the job around an explicit list of { table, retentionDays } it is permitted to touch. Suppressions and consent are simply absent from that list and therefore unreachable by the job.
  • Soft-delete or partition where possible. Time-partitioned tables let you drop a whole partition instead of issuing row deletes, far cheaper, and harder to get wrong.
Code for your engineers (TypeScript)
const RETENTION: Array<{ table: 'emailLogs' | 'emailContent'; days: number }> = [
  { table: 'emailLogs',    days: 90 },
  { table: 'emailContent', days: 30 },
  // suppressions and consent are deliberately NOT in this list and cannot be reached.
];

async function cleanupOldData() {
  const now = new Date();
  for (const { table, days } of RETENTION) {
    const cutoff = subDays(now, days);
    let deleted = 0, batch = 0;
    do {
      batch = await db[table].deleteMany({ created_at: { $lt: cutoff } }, { limit: 10_000 });
      deleted += batch;
    } while (batch > 0);
    console.log(`retention: deleted ${deleted} rows from ${table} older than ${days}d`);
    // emit a metric; alert if `deleted` is wildly outside the historical range
  }
}

How does this interact with GDPR deletion requests?

Retention sets the maximum you keep data by default. A user's right-to-erasure request can require you to delete sooner. The exception is your legal-basis records: you generally keep consent and suppression entries even after a deletion request, because they prove you had permission and protect the user from being re-mailed. Document this carve-out in your privacy policy. The legal detail is in Compliance.

There is a subtlety worth getting right: a suppression entry is itself personal data (an email address), so "keep it forever" appears to conflict with erasure. The standard resolution is to store a one-way hash of the address on the suppression list rather than the plaintext, when local law and your risk posture call for it. You check sha256(normalizeEmail(to)) against the suppression set, which still gates every send, but the row no longer contains directly readable PII, satisfying the user's erasure of their identifiable data while preserving the protection that stops you from re-mailing them. If you go this route, hash consistently (same normalization, same algorithm) on both write and read, and be aware that hashing makes the list opaque for support and debugging, so weigh the trade-off.

Which metrics tell me my list is going bad?

Watch three metrics: bounce rate, complaint rate, and suppression-list growth. They are your early-warning system.

MetricTargetAlert Threshold
Bounce rate<2%>2%
Complaint rate<0.05%>0.05%
Suppression list growthStableSudden spike

The target column is where a healthy program sits. The alert threshold column is where you should be paged.

  • Bounce rate (<2%, alert >2%). A rising bounce rate means bad addresses are entering your list: a broken validation step, a stale import, or a purchased list. Catch it early before it dents reputation (see the tighter deliverability targets in Deliverability).
  • Complaint rate (<0.05%, alert >0.05%). Even tiny complaint rates are dangerous. Crossing 0.05% should trigger an immediate investigation into what you sent and to whom.
  • Suppression list growth (stable, alert on sudden spike). Steady, slow growth is normal. A sudden spike is the signal that matters. It usually means a single send went to a bad segment, generating a burst of bounces or complaints at once. The source_email_id field from the suppression schema is what lets you trace the spike to its cause.

Calibrating the complaint rate against the 0.3% rule

The internal alert threshold above (0.05%) is deliberately stricter than the 0.3% ceiling Gmail/Yahoo/Microsoft enforce, and the gap is intentional. Two things to keep straight:

  • The 0.3% rule is measured by the mailbox provider, on their volume, as they count it. Google Postmaster Tools reports your Gmail complaint rate against Gmail recipients only. Your own internal complaint rate (complaints / delivered, across all providers) is a different, blended number. Watch both, but treat Postmaster's per-provider number as the one that determines Gmail placement.
  • 0.3% is a "do not exceed" cliff, not a target. Google's own guidance is to stay below 0.1%. By the time you hit 0.3% your deliverability is already degraded. That is why the internal page fires at 0.05%, it buys you time to react before the provider-measured rate climbs into trouble.

Compute the complaint rate over a rolling window of delivered messages, not lifetime totals, so a recent bad campaign actually moves the number. A complaint rate computed over all-time volume will mask a current problem behind years of good history.

A worked example: tracing a spike

Suppose your suppression list grows by its usual ~50 rows on Monday, then by 4,200 rows on Tuesday. The runbook:

  1. Group Tuesday's new rows by reason. Say 3,900 are hard_bounce and 300 are complaint. The hard-bounce dominance points to an address-quality problem (a bad import or list), not a content problem.
  2. Group by source_email_id. If 3,800 of the bounces share one source_email_id, you have found the offending send.
  3. Inspect that send's segment. You discover it targeted a list imported last week from an old CRM export that was never validated.
  4. Remediate: stop any further sends to that segment, run the remaining addresses through capture-time validation (MX + syntax) before re-mailing, and review how an unvalidated import reached production.

The whole investigation hinges on having stored reason and source_email_id at suppression time. Without them, a spike is a mystery you cannot resolve.

What do I do when a metric crosses its alert threshold?

  • Bounce spike: stop the affected send, find the source list or import, and run address validation before resuming (see Email Capture).
  • Complaint spike: pause the campaign immediately, review the content and segment, and confirm everyone on it actually opted in.
  • Suppression spike: use source_email_id to find the triggering send, then audit how that segment was built.

Should transactional and marketing lists be separate?

Yes. Keep them as separate lists because they play by different rules.

  • Transactional: can send to anyone with an account relationship.
  • Marketing: only opted-in subscribers.

Transactional mail (password resets, receipts, security alerts) is sent because of an existing account relationship and a specific action the user took. No marketing consent is required. Marketing mail (newsletters, promotions) may only go to people who explicitly opted in.

Separate the streams at the infrastructure level, not just the list level

The list-level separation above is the policy. The reputation-protecting move is to separate the streams physically so a problem in one cannot contaminate the other:

  • Separate sending domains or subdomains. Send transactional from one identity (e.g. notify.example.com) and marketing from another (e.g. news.example.com), each with its own SPF/DKIM/DMARC. Mailbox providers build reputation largely per-domain. If a marketing campaign tanks news.example.com, your password resets on notify.example.com keep landing. This is the single most important structural protection for transactional deliverability.
  • Separate IP pools. If your volume justifies dedicated IPs, keep transactional and marketing on different pools for the same reason, IP reputation does not bleed across.
  • Often separate providers entirely. Many teams run transactional on one ESP optimized for low-latency, high-reliability single sends and marketing on another built for campaigns and segmentation. This is a common and sound architecture, and the exact scenario where owning your own suppression list (rather than relying on each provider's) becomes essential, so a bounce on one stream suppresses on the other.

The reasoning behind domain/IP separation and the DNS records that back it are in Deliverability and Sending Reliability; the point for list management is that "separate lists" should imply "separate reputations."

Does suppression apply to both transactional and marketing?

Hard bounces and complaints suppress across all email types. A hard bounce means the address is physically undeliverable, and that is true whether the message is a receipt or a newsletter, so it suppresses everywhere. A complaint means the recipient flagged you as a spammer to their provider. Continuing to mail them anything deepens the reputation damage, so complaints also suppress across both streams.

This is precisely what the reason-aware canSendTo(email, stream) above encodes: hard_bounce and complaint are in the HARD_BLOCK_REASONS set, so they short-circuit before the stream is even consulted. unsubscribe and manual are checked only against the marketing stream.

Does unsubscribing stop transactional emails too?

No. Unsubscribe is marketing-only. A user who unsubscribes from marketing can still receive transactional emails (password resets, order confirmations).

Unsubscribing is a withdrawal of marketing consent, not a statement that the address is broken or that the user is reporting abuse. Someone who unsubscribes from your newsletter still needs their password-reset email and order confirmations to work, because those are essential to the service they are using. So an unsubscribe removes them from marketing lists only; transactional mail continues. Contrast this with a complaint, which suppresses everything. The legal nuance behind this distinction is covered in Compliance.

A common failure mode here is the "marketing message dressed as transactional", putting a promotion inside an order confirmation, or a cross-sell in a password reset. This breaks the separation in both directions: it can pull a complaint onto your transactional stream (the user reports the receipt as spam because of the promo), and it sends marketing content to people who never opted in or who unsubscribed. Keep transactional templates strictly transactional. The classification rules for what counts as which are in Email Types and the catalog in Transactional Catalog; the implementation patterns are in Transactional Emails.

What does a complete list-management checklist look like?

  • A suppression check wraps every send; no code calls the provider directly
  • The send-time gate is reason-aware and stream-aware (canSendTo(email, stream))
  • Your own suppression list is the source of truth, not the provider's
  • Hard bounces and complaints suppress in real time and are never auto-unsuppressed
  • Block bounces (5.7.x) are treated as reputation/auth signals, not suppressions
  • Soft bounces suppress after 3 failures; unsubscribes processed immediately
  • RFC 8058 one-click unsubscribe (List-Unsubscribe + List-Unsubscribe-Post) is wired to suppression for marketing
  • Addresses are normalized (lowercased, trimmed) on both write and read; suppression lookup is index-backed
  • Suppression is re-checked at dispatch time, not only at queue time
  • Inactive subscribers run through re-engagement, with a standing sunset policy behind it
  • Engagement is judged on clicks (and product activity), not opens alone, post-Apple MPP
  • Capture-time validation (syntax + MX, confirmed opt-in) keeps bad addresses out
  • Retention job runs daily, batched and observable; suppressions and consent records excluded via an allow-list
  • Bounce, complaint (against the 0.3% bulk-sender ceiling), and suppression-growth metrics monitored with alerts
  • Google Postmaster Tools and provider/FBL complaint feeds are connected for every sending domain
  • Transactional and marketing kept as separate lists and separate sending domains/IPs with the correct cross-stream rules

If this is happening in production…

Don't guess. Get a read on it

Book a 30-min reviewWe'll look at where your mail goes today and what it would take to fix it (no obligation).Book a deliverability call

Seen this in the wild: read the case studies