Files
2026-01-24 20:22:18 +00:00

4.5 KiB

List Management

Maintaining clean email lists through suppression, hygiene, and data retention.

Suppression Lists

A suppression list prevents sending to addresses that should never receive email.

What to Suppress

Reason Action Can Unsuppress?
Hard bounce Add immediately No (address invalid)
Complaint (spam) Add immediately No (legal requirement)
Unsubscribe Add immediately Only if user re-subscribes
Soft bounce (3x) Add after threshold Yes, after 30-90 days
Manual removal Add on request Only if user requests

Implementation

// 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,
  });
}

Pre-Send Check

Always check suppression before sending:

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

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

List Hygiene

Regular maintenance to keep lists healthy.

Automated Cleanup

Task Frequency Action
Remove hard bounces Real-time (via webhook) Immediate suppression
Remove complaints Real-time (via webhook) Immediate suppression
Process unsubscribes Real-time Remove from marketing lists
Review soft bounces Daily Suppress after 3 failures
Remove inactive Monthly Re-engagement → remove

Re-engagement Campaigns

Before removing inactive subscribers:

  1. Identify inactive: No opens/clicks in 90-180 days
  2. Send re-engagement: "We miss you" or "Still interested?"
  3. Wait 14-30 days for response
  4. Remove non-responders from active lists
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);
    }
  }
}

Data Retention

Email Logs

Data Type Recommended Retention Notes
Send attempts 90 days Debugging, analytics
Delivery status 90 days Compliance, reporting
Bounce/complaint events 3 years Required for CASL
Suppression list Indefinite Never delete
Email content 30 days Storage costs
Consent records 3 years after expiry Legal requirement

Retention Policy Implementation

// 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
}

Metrics to Monitor

Metric Target Alert Threshold
Bounce rate <2% >5%
Complaint rate <0.1% >0.2%
Suppression list growth Stable Sudden spike
List churn <2%/month >5%/month

Transactional vs Marketing Lists

Keep separate:

  • Transactional: Can send to anyone with account relationship
  • Marketing: Only opted-in subscribers

Suppression applies to both: Hard bounces and complaints suppress across all email types.

Unsubscribe is marketing-only: User unsubscribing from marketing can still receive transactional emails (password resets, order confirmations).