Skip to content
cd ..

Escalating Reminders Done Right: The Pending Email Follow Up System

// · 5 min read

One of the trickiest parts of building an agent email system is knowing when not to send. AgenticMail lets agents compose and queue outbound messages, but some of those messages need human approval before they go out. Maybe the content is sensitive, maybe the recipient is external, maybe the agent’s confidence score was low. Whatever the reason, the email gets blocked and sits in a pending state.

The question is: what happens next?

The Escalating Schedule

The core logic lives in pending_followup.ts. When an email is blocked awaiting approval, the system doesn’t just fire off a single notification and hope for the best. It follows an escalating reminder schedule designed to be persistent without being obnoxious.

The cadence looks like this:

  1. First reminder at 12 hours
  2. Second at 6 hours
  3. Third at 3 hours
  4. Final urgent reminder at 1 hour

After the final reminder, the system enters a 3 day cooldown period. If the email still hasn’t been approved or rejected after cooldown, the whole cycle repeats. This means a forgotten pending email will keep surfacing until someone deals with it, but it won’t spam you every hour forever.

Each escalation level carries more urgency in its notification tone. The 12 hour reminder is casual. The 1 hour reminder makes it clear this needs attention now. I found that matching the notification intensity to the elapsed time made people actually respond instead of tuning out the alerts.

Heartbeat Polling for External Resolution

There’s an interesting edge case: what if the email gets approved through some external mechanism? Maybe someone approved it through the web dashboard, or another system cleared it via the API. The follow up system can’t just blindly fire timers without checking whether the email is still actually pending.

That’s where heartbeat polling comes in. Before each scheduled reminder fires, the system does a quick check to see if the pending email still exists in its blocked state. If it’s already been resolved (approved, rejected, or expired), the follow up chain cancels itself cleanly. No phantom notifications, no confused users getting reminders about emails that went out hours ago.

This polling approach is simpler than setting up event listeners or webhooks for every possible resolution path. The pending email table is the single source of truth, and the follow up system just asks it “is this still a thing?” before each action.

Process Lifecycle: The .unref() Trick

Here’s a subtle but important detail. The follow up system uses Node.js timers (setTimeout) to schedule its reminders. By default, an active timer keeps the Node.js event loop alive, which means your process won’t exit cleanly if there are pending follow ups scheduled.

That’s a problem. If the AgenticMail server is shutting down, you don’t want zombie timers preventing a clean exit. The fix is calling .unref() on every timer. This tells Node.js “don’t keep the process alive just for this timer.” If the process is shutting down for other reasons, let it go. The timers will simply never fire, and the next time the service starts up, it’ll scan for any pending emails that still need follow ups and recreate the chains.

It’s a small thing, but getting process lifecycle right in a long running service matters. I’ve been bitten too many times by services that hang on shutdown because of forgotten timers or unclosed connections.

The drainFollowUps() Injection

For testing and graceful shutdown, the module exports a drainFollowUps() function. Calling it cancels all active follow up chains and clears the internal tracking state. This gets injected into the shutdown sequence so that when the server receives a termination signal, it can cleanly wind down all pending follow up timers before exiting.

It also makes testing dramatically easier. Each test can call drainFollowUps() in its teardown to ensure no timers leak between test cases. Without this, you end up with flaky tests where a timer from test A fires during test B and causes mysterious failures.

Why Not Cron?

I considered using a cron style scheduler instead of per email timer chains, but the escalating schedule made that awkward. Each pending email has its own timeline based on when it was blocked, and the intervals aren’t uniform. A cron job would need to scan all pending emails, calculate where each one is in its escalation cycle, and decide which ones need reminders right now. The per email timer approach is more straightforward: each email owns its own reminder chain, and the chain self manages.

Source Code

Here is the escalation schedule and the core scheduling functions. The STEP_DELAYS_MS array defines the progressively shorter intervals between reminders. scheduleFollowUp arms the chain for a given pending email, and drainFollowUps returns all accumulated notifications so the caller can process them in batch.

const STEP_DELAYS_MS = [
  12 * 3_600_000, // 12 hours
   6 * 3_600_000, //  6 hours
   3 * 3_600_000, //  3 hours
   1 * 3_600_000, //  1 hour (final before cooldown)
];
const COOLDOWN_MS = 3 * 24 * 3_600_000; // 3 days

export function scheduleFollowUp(
  pendingId: string, recipient: string, subject: string,
  checkFn: () => Promise<boolean>,
): void {
  if (tracked.has(pendingId)) return;
  arm(pendingId, recipient, subject, checkFn, 0, 0);
  startHeartbeat();
}

export function drainFollowUps(): FollowUpNotification[] {
  if (queue.length === 0) return [];
  const items = [...queue];
  queue.length = 0;
  return items;
}

View the full source on GitHub

The result is a system that’s persistent enough to ensure nothing falls through the cracks, but respectful enough that it won’t drive you crazy while doing it.

// share

// subscribe

New posts and updates straight to your inbox. No noise.

cd ..