Email addresses decay at roughly 22–30% per year: people change jobs, abandon mailboxes, and forward to new addresses. Without a periodic re-verification workflow, your list accumulates stale contacts that hard-bounce, soft-bounce repeatedly, or go to spam traps — all of which damage sender reputation. CAN-SPAM § 5 does not prescribe a specific re-verification cadence, but it does require honoring suppression and maintaining deliverability good faith. Sending to addresses that have not engaged in 12+ months is a recognized bad practice that ESPs use as a criterion for account review. A weekly scheduled process that routes contacts through a re-engagement or re-verification pipeline is the industry baseline.
High because stale contacts accumulate indefinitely without a scheduled cleanup process, eventually producing bounce and complaint rates that trigger ESP account review or suspension.
Add a scheduled job that identifies contacts inactive beyond a configurable threshold and queues them for re-verification or re-engagement:
// vercel.json cron entry or equivalent:
// { "path": "/api/cron/reverify-stale", "schedule": "0 2 * * 1" }
export async function GET() {
const STALE_DAYS = parseInt(process.env.REVERIFY_AFTER_DAYS ?? '120')
const stale = await db.$queryRaw`
SELECT id, email FROM contacts
WHERE last_engaged_at < now() - interval '${STALE_DAYS} days'
AND status = 'active'
AND verification_queued_at IS NULL
LIMIT 500
`
for (const c of stale) {
await verificationQueue.add('reverify', { contactId: c.id })
await db.contact.update({ where: { id: c.id }, data: { verification_queued_at: new Date() } })
}
return Response.json({ queued: stale.length })
}
Run this at minimum once per 7 days. Batch in groups of 500 or fewer to avoid memory pressure and external API rate limits.
ID: data-quality-list-hygiene.data-decay.reverification-trigger
Severity: high
What to look for: Count all scheduled jobs (cron, scheduled tasks, cloud functions) in the system and identify which ones process stale contacts. Quote the actual cron schedule expression found (e.g., 0 2 * * 1). Check whether the process identifies contacts who have not engaged within a configurable period (typically 90-180 days) and initiates a re-verification or re-engagement workflow. Look for scheduled tasks that query last_engaged_at < now() - interval N days.
Pass criteria: At least 1 scheduled process runs no less than once per 7 days to identify disengaged contacts and either initiates re-verification (deliverability check) or routes them to a re-engagement campaign before they are suppressed.
Fail criteria: No re-verification process exists; disengaged contacts accumulate indefinitely and are sent to on equal footing with active contacts.
Skip (N/A) when: The system sends only transactional email triggered by user actions (no marketing campaigns where engagement staleness matters).
Detail on fail: Example: "No cron job or scheduled function processes stale contacts — contacts from 2022 are still on the active send list" or "Re-engagement logic exists but has not run in over 90 days based on last execution log"
Remediation: Add a scheduled verification job:
// Using Vercel cron or similar:
// cron: "0 2 * * 1" (every Monday at 2am UTC)
export async function GET() {
const STALE_THRESHOLD_DAYS = 120
const staleContacts = await db.$queryRaw`
SELECT id, email
FROM contacts
WHERE
last_engaged_at < now() - interval '${STALE_THRESHOLD_DAYS} days'
AND status = 'active'
AND verification_queued_at IS NULL
LIMIT 500
`
for (const contact of staleContacts) {
await verificationQueue.add('reverify', { contactId: contact.id })
await db.contact.update({
where: { id: contact.id },
data: { verification_queued_at: new Date() }
})
}
return Response.json({ queued: staleContacts.length })
}