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.)
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.
1function processMail() {
2}
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.
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.)
1function processMail() {
2 const rules = [
3 markArchivedRead
4 ];
5 for (let rule of rules) {
6 rule();
7 }
8}
9
10function markArchivedRead() {
11 return eachThread("gmail: mark archived threads read",
12 "-in:inbox is:unread",
13 function(thread) { thread.markRead(); });
14}
15
16function eachThread(operation, query, f) {
17 const threads = GmailApp.search(query).slice(0, 100);
18 if (threads.length <= 0) {
19 Logger.log("%s: 0 threads match query %s", operation, query);
20 return;
21 }
22 const n = threads.length;
23 Logger.log("%s: %s threads match query %s", operation, n, query);
24 for (let thread of threads) {
25 f(thread);
26 }
27 Logger.log("%s: processed %s threads, done!", n, operation);
28}
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:
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.
1function limitInbox() {
2 const max = 100;
3 const op = "gmail: limit inbox";
4 const purged = GmailApp.getUserLabelByName("optional/purged");
5 const threads = GmailApp.search("in:inbox label:optional");
6 Logger.log("%s: %s optional threads", op, threads.length);
7 if (threads.length <= max) {
8 Logger.log("%s: done!", op);
9 return;
10 }
11 let n = 0;
12 for (let thread of threads.slice(Math.floor(max/2))) {
13 if (!thread.hasStarredMessages()) {
14 thread.addLabel(purged);
15 thread.moveToArchive();
16 n++;
17 }
18 }
19 if (!onVacation()) {
20 pushSMS(`auto-archived ${n} non-essential emails!`);
21 }
22 Logger.log("%s: archived %s threads, done!", n);
23}
24
25function onVacation() {
26 const email = Session.getEffectiveUser().getEmail();
27 const cal = CalendarApp.getCalendarById(email);
28 for (let event of cal.getEventsForDay(new Date())) {
29 let t = event.getTitle();
30 if (t.includes("OOO") || t.includes("PTO") {
31 return true;
32 }
33 }
34 return false;
35}
36
37function pushSMS(msg) {
38 // T-Mobile, Sprint, Verizon, and AT&T all support email-to-SMS
39 GmailApp.sendEmail('1234567890@vtext.com', 'AppsScript', msg);
40}
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.
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.
1function queueLitRPG() {
2 const op = "gmail: queue litRPG";
3 const threads = GmailApp
4 .search('in:inbox from:royalroad.com subject:"New Chapter of"');
5 let unread = {};
6 for (let thread of threads) {
7 const book = thread
8 .getFirstMessageSubject()
9 .replace(/New Chapter of/, '')
10 .trim();
11 const chapter = {
12 date: thread.getLastMessageDate(),
13 thread: thread
14 };
15 if (unread[book] == undefined) {
16 unread[book] = [chapter];
17 } else {
18 unread[book].push(chapter);
19 }
20 }
21 for (const [book, chapters] of Object.entries(unread)) {
22 // sort most recent first
23 const sorted = chapters
24 .slice()
25 .sort((a, b) => b.date - a.date);
26 // keep the oldest
27 for (let chapter of sorted.slice(0, -1)) {
28 chapter.thread.moveToArchive();
29 }
30 Logger.log("%s: done with %s", op, book);
31 }
32}
I use a similar approach to:
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: