Automating Gmail with AppsScript

Jun 2020

We’re all drowning in email. The problem isn’t the blatant spam—it’s easy to unsubscribe from most of that and block the rest. The real killer is the email that’s sort of interesting: discussion on projects you’re peripherally involved in, weekly newsletters, chatter on GitHub issues you filed months ago, and all the other mail you skim when you have the time and skip if you’re busy.

Triaging these emails manually can be an exhausting game of whack-a-mole. But if you’re a Gmail user, there’s hope—you can use AppsScript to automate big parts of the job. Best of all, it’s free! In this post, I’ll walk you through creating your first AppsScript project and give you a taste of what you can accomplish. (Hat tip to Prashant Varanasi, who first introduced me to AppsScript.)

Setup

Create a new AppsScript project, then add a small function and save the project. This code doesn’t do anything yet, but we’ll add to it later on.

function processMail() {
}

AppsScript includes a cron-like triggers service, but you have to be careful: Google limits the amount of time your script spends running, and it also limits the number of Gmail operations you can perform. For me, running my email management script every 15 minutes keeps my inbox nicely groomed without blowing through my quotas. Set up a time-based trigger for the processMail function right from the editor by going to “Edit,” then “Current project’s triggers,” and finally “Add Trigger” in the bottom right.

Simple: Mark Archived Threads Read

The simplest bits of my email automation work on one thread at a time, with no further context required: for example, I use a small function to make sure that all my archived email is marked read. (It’s puzzling to me that this isn’t the default, since it’s the only way to make the badge count on Gmail’s mobile apps useful.)

function processMail() {
  const rules = [
    markArchivedRead
  ];
  for (let rule of rules) {
    rule();
  }
}

function markArchivedRead() {
  return eachThread("gmail: mark archived threads read",
                    "-in:inbox is:unread",
                    function(thread) { thread.markRead(); });
}

function eachThread(operation, query, f) {
  const threads = GmailApp.search(query).slice(0, 100);
  if (threads.length <= 0) {
    Logger.log("%s: no threads matching query %s", operation, query);
    return;
  }
  const n = threads.length;
  Logger.log("%s: found %s threads matching query %s", operation, n, query);
  for (let thread of threads) {
    f(thread);
  }
  Logger.log("%s: processed %s threads, done!", n, operation);
}

I have 5–10 small tasks like this active most of the time, and the eachThread helper keeps each of them nice and short. Note that eachThread limits itself to processing 100 threads per invocation, keeping execution time short and capping the number of Gmail operations consumed.

I use similar per-thread functions for a variety of simple tasks, most of which either groom my archived mail or trim my inbox when I start falling behind. Examples include:

Moderate: Limit Inbox Size

Despite my best intentions, newsletters and other non-essential threads often accumulate in my inbox. It’s painful to clear this backlog out by hand, because I actually want to read most of it: I end up agonizing over whether I’ve got time to read just one more interesting article or thread, opening a million browser tabs, and burning hours of time better spent elsewhere.

Instead, I use filters to label interesting-but-optional mail as it arrives. (I have a lot of filters, which I recently started managing with gmailctl.) If more than a hundred of these emails pile up in my inbox, I archive the older messages until only 50 remain. I also tag the auto-archived messages, so I know that I haven’t read them if they show up in search later on.

function limitInbox() {
  const max = 100;
  const op = "gmail: limit inbox";
  const purged = GmailApp.getUserLabelByName("optional/purged");
  const threads = GmailApp.search("in:inbox label:optional");
  Logger.log("%s: %s optional threads", op, threads.length);
  if (threads.length <= max) {
    Logger.log("%s: done!", op);
    return;
  }
  let n = 0;
  for (let thread of threads.slice(Math.floor(max/2))) {
    if (!thread.hasStarredMessages()) {
     thread.addLabel(purged);
     thread.moveToArchive();
     n++;
    }
  }
  if (!onVacation()) {
    pushSMS(`auto-archived ${n} non-essential emails!`);
  }
  Logger.log("%s: archived %s threads, done!", n);
}

function onVacation() {
  const email = Session.getEffectiveUser().getEmail();
  const cal = CalendarApp.getCalendarById(email);
  for (let event of cal.getEventsForDay(new Date())) {
    let t = event.getTitle();
    if (t.includes("OOO") || t.includes("PTO") || t.includes("Vacation") || t.includes("vacation")) {
      return true;
    }
  }
  return false;
}

function pushSMS(msg) {
    // T-Mobile, Sprint, Verizon, and AT&T all support email-to-SMS
    GmailApp.sendEmail('1234567890@vtext.com', 'AppsScript', msg);
}

With this de-bulking script active, going on vacation or getting busy for a week doesn’t leave me with an hour-long inbox cleanup chore. It’s surprisingly liberating.

I use the onVacation and pushSMS functions regularly: the first lets me toggle vacation-only behavior with minimal effort, and the second notifies me if my scripts are running amok.

Complex: Reduce Notification Spam

The most complex portions of my AppsScript project selectively archive notifications. Code review systems like Phabricator and GitHub, exception trackers like Sentry, and many RSS-like subscriptions send tons of notifications. Often, I’m only interested in the oldest or newest unread notification for each item.

For example, I love reading trashy, RPG-inspired web novels on Royal Road. They send me an email each time a new chapter gets published in a book I’m following, but I only catch up on my trashy reading a few times a week. Rather than letting all the notifications sit in my inbox, I’d rather keep only the oldest email for each book.

function queueLitRPG() {
  const op = "gmail: queue litRPG";
  const threads = GmailApp.search('in:inbox from:royalroad.com subject:"New Chapter of"');
  let unread = {};
  for (let thread of threads) {
    const book = thread.getFirstMessageSubject().replace(/New Chapter of/, '').trim();
    const chapter = {
      date: thread.getLastMessageDate(),
      thread: thread
    };
    if (unread[book] == undefined) {
      unread[book] = [chapter];
    } else {
      unread[book].push(chapter);
    }
  }
  for (const [book, chapters] of Object.entries(unread)) {
    // sort most recent first
    const sorted = chapters.slice().sort((a, b) => b.date - a.date);
    // keep the oldest, since that's where I left off reading
    for (let chapter of sorted.slice(0, -1)) {
      chapter.thread.moveToArchive();
    }
    Logger.log("%s: done with %s", op, book);
  }
}

I use a similar approach to:

Calendars: The Final Frontier

I haven’t worked much with the calendar support in AppsScript yet, but there’s so much low-hanging fruit. I’d love to try: