appscript.dev
Automation Intermediate Gmail

Snooze-and-resurface follow-up reminders

Re-flag a thread in your inbox if nobody has replied within N days.

Published Oct 7, 2025

You send an email that needs a reply, the other side goes quiet, and the thread quietly sinks down the inbox. Two weeks later you remember it — usually too late. Chasing replies is exactly the kind of admin that depends on memory, and memory is the thing that fails first when the week gets busy.

At Northwind, Awadesh marks the threads that matter with a follow-up label — followup/3d, followup/7d, or followup/14d — to declare how long he is willing to wait. This script runs every morning, checks each labelled thread, and resurfaces any where the wait has elapsed and the other side still has not replied. The thread jumps back to the top of the inbox marked unread, so the chase happens on schedule instead of by luck.

What you’ll need

  • Gmail labels named followup/3d, followup/7d, and followup/14d. Create them under Settings → Labels, or let them appear the first time you apply one to a thread.
  • Nothing else — the script runs against your own mailbox with no extra setup.

The script

// Each follow-up label maps to the number of days you're willing to wait
// before the thread should pop back into your inbox.
const STAGES = {
  'followup/3d': 3,
  'followup/7d': 7,
  'followup/14d': 14,
};

// One day in milliseconds — used to turn a wait period into a cutoff time.
const DAY_MS = 86400000;

/**
 * Walks every follow-up label and resurfaces any thread that has been
 * waiting longer than its label allows, as long as you're the last one
 * to have spoken. Designed to run on a daily trigger.
 */
function resurfaceFollowups() {
  // Your own address — used to tell "I'm still waiting" from "they replied".
  const me = Session.getActiveUser().getEmail();

  for (const [labelName, days] of Object.entries(STAGES)) {
    // 1. Skip a stage cleanly if its label doesn't exist yet.
    const label = GmailApp.getUserLabelByName(labelName);
    if (!label) continue;

    // 2. Anything whose last message predates this cutoff is overdue.
    const cutoff = Date.now() - days * DAY_MS;

    for (const thread of label.getThreads()) {
      const messages = thread.getMessages();
      const last = messages[messages.length - 1];

      // 3. Still inside the wait window — leave it alone for now.
      if (last.getDate().getTime() > cutoff) continue;

      // 4. The other side already replied — no chase needed. We only
      //    resurface when the last word in the thread was yours.
      if (last.getFrom().includes(me)) {
        // They replied: drop the label so it stops being tracked.
        thread.removeLabel(label);
        continue;
      }

      // 5. Overdue and unanswered: pull it back to the top of the inbox.
      thread.markUnread();
      thread.moveToInbox();

      // Remove the label so it isn't resurfaced again tomorrow.
      thread.removeLabel(label);
    }
  }
}

How it works

  1. STAGES is the whole configuration — each followup/Nd label is paired with the number of days you are prepared to wait before being reminded.
  2. For each stage, the script looks up the label and skips quietly if you have not created it yet, so a missing label is never an error.
  3. It works out a cutoff timestamp: any thread whose most recent message is older than that has been silent long enough to count as overdue.
  4. For each labelled thread it inspects the last message. If that message is newer than the cutoff, the wait is still on and the thread is left untouched.
  5. If the last message is from you, you are still the one waiting — so an overdue thread gets resurfaced: marked unread and moved back into the inbox.
  6. If the last message is from the other side, they have replied. There is nothing to chase, so the script simply removes the label and moves on.
  7. Either way, the label is removed once a thread has been handled. If you want another round of chasing, re-apply the label by hand.

Example run

Suppose three threads carry follow-up labels when the trigger fires on a Monday:

ThreadLabelLast messageLast senderOutcome
”Retainer renewal — Acme”followup/3d5 days agoyouResurfaced, marked unread, label removed
”Invoice query — Belmont”followup/7d2 days agoyouStill within the wait window — untouched
”Brief feedback — Crane Co”followup/3d4 days agoCrane CoThey replied — label removed, no resurface

Only the Acme thread lands back at the top of the inbox. The Belmont thread is still inside its 7-day window, and the Crane Co thread is closed out because the reply already arrived.

Trigger it

This is a scheduled job — run it once a day so overdue threads surface each morning:

  1. In the Apps Script editor, open Triggers (the clock icon).
  2. Click Add Trigger.
  3. Choose resurfaceFollowups, event source Time-driven, type Day timer, and pick the 8am–9am slot.
  4. Save, and approve the authorisation prompt the first time it runs.

Watch out for

  • The script judges “they haven’t replied” purely by the last message in the thread. If someone replies only to remove themselves from a thread, or sends an out-of-office auto-reply, that still counts as a reply and the label is dropped.
  • Resurfacing removes the label. That is deliberate — it stops the same thread being flagged every single day — but it also means one resurface per label application. Re-apply the label if you want to keep chasing.
  • last.getFrom().includes(me) is a substring match. It is reliable for a normal address, but if you send from an alias the comparison may miss. Add your aliases to the check if you use them.
  • Threads with hundreds of messages cost more to read. If a label collects very long threads, expect each run to take longer; keep the labels for genuine follow-ups rather than every conversation.
  • The wait period is measured from the last message, not from when you applied the label. Label an already-stale thread and it may resurface on the very next run.

Related