A binary active/suppressed flag treats a contact who opened an email yesterday identically to one who last engaged 5 months ago. Without intermediate engagement tiers, you cannot implement frequency capping for at-risk contacts, sunset programs for lapsed contacts, or segment-specific content strategies — all of which are proven tactics for preserving list health. Flat lists also make it impossible to diagnose why overall open rates are declining, because you cannot isolate the drag from a growing lapsed cohort. Sending at full frequency to all non-suppressed contacts regardless of recency is a recognized deliverability antipattern that contributes to spam trap hits as dormant mailboxes get recycled.
Medium because a single active/suppressed binary prevents the frequency management needed to protect sender reputation from lapsed-contact drag, indirectly accelerating deliverability decay.
Implement at least three engagement tiers and recompute them on a nightly schedule:
type EngagementTier = 'active' | 'at_risk' | 'inactive' | 'lapsed'
function computeTier(lastEngagedAt: Date | null): EngagementTier {
if (!lastEngagedAt) return 'lapsed'
const days = (Date.now() - lastEngagedAt.getTime()) / 86_400_000
if (days <= 30) return 'active'
if (days <= 90) return 'at_risk'
if (days <= 180) return 'inactive'
return 'lapsed'
}
Store engagement_tier on the contact record and recompute nightly via a bulk UPDATE. Use engagement_tier IN ('active', 'at_risk') in campaign segment queries; route inactive contacts to lower-frequency sends; suppress lapsed contacts from all sends pending a sunset re-engagement.
ID: data-quality-list-hygiene.data-decay.engagement-scoring
Severity: medium
What to look for: Check whether contacts have an engagement score or tier classification (active, at-risk, inactive, lapsed) that is maintained and used in send decisions. Count the number of engagement tiers defined — at least 3 tiers are required for meaningful segmentation. Look for a numeric score column, a status enum with engagement-based values, or a segment query that filters by engagement. This is more nuanced than a binary active/suppressed flag. A binary active/suppressed flag with no intermediate states does not count as pass.
Pass criteria: Contacts have a computed engagement score or classification with at least 3 distinct tiers that reflects recent activity. Send logic or segment queries use this score to differentiate send frequency, list inclusion, or campaign eligibility.
Fail criteria: No engagement scoring exists; contacts are either fully active or suppressed with fewer than 3 intermediate states.
Skip (N/A) when: The list is small enough (<500 contacts) that manual management is practical, or all email is transactional.
Detail on fail: Example: "Contacts have a binary active/suppressed field only — no engagement scoring or tiered freshness classification"
Remediation: Implement a simple engagement tier system:
type EngagementTier = 'active' | 'at_risk' | 'inactive' | 'lapsed'
function computeEngagementTier(lastEngagedAt: Date | null): EngagementTier {
if (!lastEngagedAt) return 'lapsed'
const daysSinceEngagement = (Date.now() - lastEngagedAt.getTime()) / 86_400_000
if (daysSinceEngagement <= 30) return 'active'
if (daysSinceEngagement <= 90) return 'at_risk'
if (daysSinceEngagement <= 180) return 'inactive'
return 'lapsed'
}
// Recompute nightly:
await db.$executeRaw`
UPDATE contacts SET engagement_tier = CASE
WHEN last_engaged_at > now() - interval '30 days' THEN 'active'
WHEN last_engaged_at > now() - interval '90 days' THEN 'at_risk'
WHEN last_engaged_at > now() - interval '180 days' THEN 'inactive'
ELSE 'lapsed'
END
`