Re-verification triggered after engagement inactivity
Why it matters
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.
Severity rationale
High because stale contacts accumulate indefinitely without a scheduled cleanup process, eventually producing bounce and complaint rates that trigger ESP account review or suspension.
Remediation
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.
Detection
-
ID:
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 querylast_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 }) }
External references
- iso-25010:2011 · functional-correctness — Functional Correctness (functional suitability)
- external · CAN-SPAM-Sec5 — CAN-SPAM Act §5 — commercial email must honor opt-out requests promptly
Taxons
History
- 2026-04-18·v1.0.0·Initial import from data-quality-list-hygiene·automated