# Re-verification triggered after engagement inactivity

- **Pattern:** `ab-000865` (`data-quality-list-hygiene.data-decay.reverification-trigger`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000865
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000865)

## 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:

```ts
// 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 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:

  ```ts
  // 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 functional-correctness
- external CAN-SPAM-Sec5 — https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business

Taxons: data-integrity

HTML version: https://auditbuffet.com/patterns/ab-000865
