appscript.dev
Automation Advanced Gmail

Bulk-unsubscribe from senders you never open

Score senders by open rate over 90 days and stage unsubscribe actions for the worst offenders.

Published Dec 2, 2025

Marketing email accumulates quietly. A webinar sign-up here, a checkout there, and within a year the promotions tab is a wall of mail nobody reads. Clearing it by hand means opening each newsletter, scrolling to the tiny footer link and clicking through — tedious enough that most people just keep deleting instead.

Awadesh’s inbox at Northwind had reached exactly that state. This script does the boring part: it scans the last 90 days of promotional mail, scores every sender by how often their messages go unread, and writes the worst offenders — those he opens almost never — into a review Sheet, each with its unsubscribe link already extracted. He skims the Sheet once a quarter and clicks only the links he wants. The script never unsubscribes for him; it just removes the hunting.

What you’ll need

  • A Google Sheet to hold the review list. The script writes to the first tab and clears it on each run, so use a dedicated, empty sheet.
  • The Sheet’s ID, set as REVIEW_SHEET in the config below.
  • Nothing else — the script reads your existing promotions mail and writes the results. It makes no changes to your inbox.

The script

// The Sheet that holds the review list. Use a dedicated, empty sheet — the
// first tab is cleared and rewritten on every run.
const REVIEW_SHEET = '1abcUnsubReviewSheetId';

// Only consider mail from the last 90 days.
const WINDOW = 'newer_than:90d';

// A sender must have sent at least this many messages to be ranked — one
// stray email is not a pattern.
const MIN_MESSAGES = 3;

// Flag a sender only if at least this fraction of their mail went unread.
const UNREAD_THRESHOLD = 0.9;

/**
 * Scans promotional mail, scores each sender by unread rate, and writes the
 * senders you almost never open into the review Sheet.
 */
function rankUnreadSenders() {
  // email -> { total, unread, unsub }
  const senders = new Map();

  // 1. Pull every promotional thread from the last 90 days.
  const threads = GmailApp.search('category:promotions ' + WINDOW);
  if (!threads.length) {
    Logger.log('No promotional mail in the window — nothing to do.');
    return;
  }

  // 2. Tally each sender's totals from the first message of every thread.
  for (const thread of threads) {
    const msg = thread.getMessages()[0];
    const from = msg.getFrom();

    const stats = senders.get(from) || { total: 0, unread: 0, unsub: null };
    stats.total++;
    if (msg.isUnread()) stats.unread++;
    // Grab the unsubscribe link once — the first message that has one wins.
    if (!stats.unsub) stats.unsub = findUnsubLink(msg);
    senders.set(from, stats);
  }

  // 3. Keep only senders that are frequent, almost never opened, and have a
  //    usable unsubscribe link. Sort the worst offenders to the top.
  const rows = [...senders.entries()]
    .filter(([, s]) =>
      s.total >= MIN_MESSAGES
      && s.unread / s.total > UNREAD_THRESHOLD
      && s.unsub)
    .sort((a, b) => b[1].total - a[1].total)
    .map(([from, s]) => [from, s.total, s.unread, s.unsub]);

  // 4. Rewrite the review Sheet from scratch with a header and one row each.
  const sheet = SpreadsheetApp.openById(REVIEW_SHEET).getSheets()[0];
  sheet.clear();
  sheet.getRange(1, 1, 1, 4)
    .setValues([['sender', 'sent', 'unread', 'unsubscribe']]);
  if (rows.length) {
    sheet.getRange(2, 1, rows.length, 4).setValues(rows);
  }
  Logger.log('Flagged ' + rows.length + ' sender(s) for review.');
}

/**
 * Extracts the first unsubscribe / opt-out link from a message's HTML body.
 * Returns the URL, or null if none is found.
 */
function findUnsubLink(msg) {
  const body = msg.getBody();
  const match = body.match(
    /href="(https?:\/\/[^"]+(?:unsubscribe|opt[-_]?out)[^"]*)"/i,
  );
  return match ? match[1] : null;
}

How it works

  1. rankUnreadSenders searches Gmail for category:promotions mail from the last 90 days. If there is none, it logs a message and stops.
  2. For each thread it reads the first message and updates a per-sender tally: total messages, how many were unread, and an unsubscribe link captured the first time one appears.
  3. Once every thread is counted, it filters the senders down to those that meet all three tests — at least three messages, an unread rate above 90%, and a usable unsubscribe link — then sorts the highest-volume offenders first.
  4. findUnsubLink does the link extraction with a regular expression that looks for an href pointing at a URL containing unsubscribe or opt-out. This covers the overwhelming majority of legitimate marketing footers.
  5. It clears the review Sheet, writes a header row, and drops one row per flagged sender: who they are, how much they sent, how much went unread, and the link to act on.

Example run

After a quarter of accumulated promotions, a run might write this to the review Sheet:

sendersentunreadunsubscribe
Daily Deals [email protected]4141https://dailydeals.example/unsub?u=8a2
Webinar Hub [email protected]1817https://webinarhub.example/opt-out/3f1
Gadget Weekly [email protected]99https://gadgetweekly.example/unsubscribe

Three senders, 68 emails, almost none of them ever opened. Awadesh clicks the links he agrees with and ignores the rest — a two-minute job instead of an afternoon of footer-hunting.

Run it

This is a quarterly tidy-up, not a background job, so run it by hand:

  1. In the Apps Script editor, select rankUnreadSenders and click Run.
  2. Approve the authorisation prompt the first time.
  3. Open the review Sheet and click whichever unsubscribe links you want to action.

If you would rather not visit the editor, add a custom menu so it can be triggered from the Sheet itself:

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('Inbox tools')
    .addItem('Rank unread senders', 'rankUnreadSenders')
    .addToUi();
}

Watch out for

  • Gmail does not expose true open events to Apps Script. This script treats isUnread as a proxy for “ignored” — accurate enough for promotional mail you never click into, but noisy for anything you read in the preview pane without opening.
  • It only inspects the first message of each thread. A long promotional thread is rare, but where one exists only its opening message is scored.
  • The unsubscribe regex is good, not perfect. Some senders bury the link behind a redirect, an image, or a List-Unsubscribe header instead of a footer link — those senders simply will not appear in the Sheet.
  • A category:promotions search over 90 days can return a large number of threads. If a run is slow or hits the script time limit, narrow the window to newer_than:30d and run it more often.
  • The script never clicks anything. Unsubscribing is left entirely to you — which is the point, since some “promotions” are mail you actually want to keep.

Related