Back to Novu

List Management

.agents/skills/email-best-practices/resources/list-management.md

3.15.04.5 KB
Original Source

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

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

Implementation

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

Pre-Send Check

Always check suppression before sending:

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 resend.emails.send({ to, ...emailData });
}

List Hygiene

Regular maintenance to keep lists healthy.

Automated Cleanup

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 → remove

Learn more: https://resend.com/docs/knowledge-base/audience-hygiene

Re-engagement Campaigns

Before removing inactive subscribers:

  1. Identify inactive: No opens/clicks in 45-90 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
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);
    }
  }
}

Data Retention

Email Logs

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

Retention Policy Implementation

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
}

Metrics to Monitor

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

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).