Skip to content

Instantly share code, notes, and snippets.

@dead-claudia
Last active December 18, 2023 21:37
Show Gist options
  • Star 20 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save dead-claudia/63716b78c58b116c8eb7 to your computer and use it in GitHub Desktop.
Save dead-claudia/63716b78c58b116c8eb7 to your computer and use it in GitHub Desktop.
Gmail script filter based on search queries

Gmail Filter by Search Query

This program, in the form of a configuration script and a main script, allows for complicated Gmail search queries to be used as filters. It also lets you do more advanced stuff you can't do with ordinary filters, like label based on whether an email contains a specific kind of attachment.

Installing

  1. Go to script.google.com.
  2. Go to File > New > Script File, and type Main as the title for the new script. This will be for the main script.
  3. Delete any pre-filled text in the script file, and copy main.gs from this gist to that file.
  4. Go to File > New > Script File again, and type Config as the title for the new script. This will be for configuration.
  5. Delete any pre-filled text in the script file, and enter the desired configuration.
  6. Click Run > Run function > __install to install.
  7. If a dialog comes up to approve permissions, disregard any warning of Google considering it unsafe (as it's obviously not signed, approved, or anything) and accept them. This script doesn't send anything to any server, nor does it attempt to do anything malicious behind your back - the source code isn't even obfuscated here.

Updating

If there's a bug, new feature, or something you want, you can update it pretty easily, too.

  1. Go to the script file you created when installing.
  2. Click the "Config" script and update it as necessary.
  3. Click the "Main" script.
  4. Copy the updated script into it.
  5. Click Run > Run function > __install to update.
  6. If a dialog comes up to approve permissions, accept them. This script doesn't send anything to any server, nor does it attempt to do anything malicious behind your back.

Uninstalling

If you, for any reason, wish to uninstall the script and revert back to what you used previously, here's how you do it.

  1. Go to the script file you created when installing.
  2. Click Run > Run function > __uninstall to uninstall.
  3. Delete your script from Google Apps Script if you wish.

Configuration

Configuration is a simple __setup = {...} block with two options queries and notify. If you're non-technical, it's not as complicated as you might think. A basic configuration file might look like this:

__setup = {
    // The queries to run filters over
    queries: [
        ["some query", function (thread) { /* ... */ }],
        ["another query", function (thread) { /* ... */ }],
    ],
    // Info for the weekly summary. Set to `false` instead to just do it in the
    // background.
    notify: {
        // The email the script runs under - must be a valid email.
        email: "you@domain.example",
        // The subject for the weekly summary
        subject: "Filter Summary",
        // The email body as plain text - `%c` acts as a placeholder for the
        // filtered count
        body: "%c emails processed in the past 7 days.",
    },
};

queries is required to know which emails to filter, and the thread in each of those is a GmailThread for you to do things with. notify provides various options for the weekly summary, emailed Monday every week at 6 in the morning.

You can omit notify or just set it to true, in which it just defaults to this:

__setup = {
    // ...
    notify: {
        email: "The email of the account you used to create the script",
        subject: "Weekly Filter Totals",
        body: "Number of emails successfully processed this past week: %c",
    },
};

You can also omit individual parts of notify (like email) to default to one of these. Note that if you specify notify without email and it can't detect the email for you, it will return an error and let you know it has to be specified explicitly.

Example configuration

Here's an example configuration based on my own one.

// Helper method. One caveat to be aware of is that you should not start
// variables with two underscores - those are reserved for internal use.
function trash(thread) {
  thread.moveToTrash();
}

__setup = {
    queries: [
      ["in:all -in:trash category:social older_than:15d -is:starred", trash],
      ["in:all -in:trash category:updates older_than:15d -is:starred -label:Important-Label", trash],
      ["in:all -in:trash category:promotions older_than:15d -is:starred -label:Company-News", trash],
      ["in:all -in:trash category:forums older_than:90d -is:starred", trash],
    ],
};

Here's another configuration, one that just adds a Company label to company emails with a more informative custom body:

__setup = {
    queries: [
        ["from:my.name@company.example", function (thread) {
            thread.addLabel(GmailApp.getUserLabelByName("Company"));
        }],
    ],
    notify: {
        body: "%c threads from Company labeled",
    },
};

Note: the "Company" label must have already been created for this to work.

If you want to iterate emails, not just threads, use GmailThread#getMessages and iterate it like this:

function iterate(thread) {
    thread.getMessages().forEach(function (email) {
        // Do something with `email`
    });
}

This is useful for seeing if a thread has a PDF attachment and acting accordingly, something you can't do using Gmail's built-in filters:

__setup = {
    queries: [
        ["in:all -in:trash -label:has-pdf", function (thread) {
            var hasPDF = false
            thread.getMessages().forEach(function (email) {
                email.getAttachments().forEach(function (attachment) {
                    if (attachment.getContentType() === "application/pdf") hasPDF = true;
                });
            });
            
            if (hasPDF) {
                thread.addLabel(GmailApp.getUserLabelByName("Has PDF"));
            }
        }],
    ],
};

Note: the "Has PDF" label must have already been created for this to work.

Of course, if you're a programmer, you probably already noticed this could've been done a lot more efficiently:

// If you know JS well enough
var label = GmailApp.getUserLabelByName("Has PDF");
__setup = {
    queries: [
        ["in:all -in:trash -label:has-pdf", function (thread) {
            if (thread.getMessages().some(function (email) {
                return email.getAttachments().some(function (attachment) {
                    return attachment.getContentType() === "application/pdf";
                });
            })) {
                thread.addLabel(label);
            }
        }],
    ],
};

// Or maybe if you're used to Java or C++:
var label = GmailApp.getUserLabelByName("Has PDF");
__setup = {
    queries: [
        ["in:all -in:trash -label:has-pdf", function (thread) {
            var messages = thread.getMessages();
            for (var i = 0; i < messages.length; i++) {
                var attachments = messages[i].getAttachments();
                for (var j = 0; j < attachments.length; j++) {
                    if (attachments[j].getContentType() === "application/pdf") {
                        thread.addLabel(label);
                        return;
                    }
                }
            }
        }],
    ],
};

License

Blue Oak Model License 1.0.0. The full text is in main.gs as well as in LICENSE.md in this gist.

Blue Oak Model License

Version 1.0.0

Purpose

This license gives everyone as much permission to work with this software as possible, while protecting contributors from liability.

Acceptance

In order to receive this license, you must agree to its rules. The rules of this license are both obligations under that agreement and conditions to your license. You must not do anything with this software that triggers a rule that you cannot or will not follow.

Copyright

Each contributor licenses you to do everything with this software that would otherwise infringe that contributor's copyright in it.

Notices

You must ensure that everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license or a link to https://blueoakcouncil.org/license/1.0.0.

Excuse

If anyone notifies you in writing that you have not complied with Notices, you can keep your license by taking all practical steps to comply within 30 days after the notice. If you do not do so, your license ends immediately.

Patent

Each contributor licenses you to do everything with this software that would otherwise infringe any patent claims they can license or become able to license.

Reliability

No contributor can revoke this license.

No Liability

As far as the law allows, this software comes as is, without any warranty or condition, and no contributor will be liable to anyone for any damages related to this software or this license, under any kind of legal claim.

/**
* Copyright (c) 2019 and later, Isiah Meadows <contact@isiahmeadows.com>.
* Source + Docs: https://gist.github.com/isiahmeadows/63716b78c58b116c8eb7
*
* # Blue Oak Model License
*
* Version 1.0.0
*
* ## Purpose
*
* This license gives everyone as much permission to work with this software as
* possible, while protecting contributors from liability.
*
* ## Acceptance
*
* In order to receive this license, you must agree to its rules. The rules of
* this license are both obligations under that agreement and conditions to your
* license. You must not do anything with this software that triggers a rule
* that you cannot or will not follow.
*
* ## Copyright
*
* Each contributor licenses you to do everything with this software that would
* otherwise infringe that contributor's copyright in it.
*
* ## Notices
*
* You must ensure that everyone who gets a copy of any part of this software
* from you, with or without changes, also gets the text of this license or a
* link to <https://blueoakcouncil.org/license/1.0.0>.
*
* ## Excuse
*
* If anyone notifies you in writing that you have not complied with Notices,
* you can keep your license by taking all practical steps to comply within 30
* days after the notice. If you do not do so, your license ends immediately.
*
* ## Patent
*
* Each contributor licenses you to do everything with this software that would
* otherwise infringe any patent claims they can license or become able to
* license.
*
* ## Reliability
*
* No contributor can revoke this license.
*
* ## No Liability
*
* ***As far as the law allows, this software comes as is, without any warranty
* or condition, and no contributor will be liable to anyone for any damages
* related to this software or this license, under any kind of legal claim.***
*/
/* global Session, LockService, Logger, PropertiesService, */
/* global ScriptApp, GmailApp, __setup */
/* exported __setup, __install, __uninstall, __runQueries, __emailResults */
var __i, __u, __r, __e;
function __install() { __i(); }
function __uninstall() { __u(); }
function __runQueries() { __r(); }
function __emailResults() { __e(); }
(() => {
"use strict";
const opts = __setup
function writeLine(str) {
Logger.log(`== Timed Filters == ${str != null ? str : ''}`);
}
writeLine('LOG: Initializing script.');
// Increment this any time a breaking change occurs
const ScriptVersion = 1;
// Helpful message in case of validation error
const invalidSuffix =
'Please fix this as soon as possible. Documentation for this script can ' +
'be found at https://gist.github.com/isiahmeadows/63716b78c58b116c8eb7.';
function require(errs, name, obj, types, isOptional) {
if (obj == null) {
if (isOptional) return;
} else {
for (const type of types) {
if (type === 'array') {
if (Array.isArray(obj)) return;
} else if (type === 'integer') {
if (typeof obj === 'number' && obj % 1 === 0) return;
} else {
if (typeof obj === type) return;
}
}
}
let message = `${name} must be a${/^[aeiou]/.test(types[0]) ? 'n' : ''} `;
if (types.length === 1) {
message += types[0]
} else if (types.length === 2) {
message += `${types[0]} or ${types[1]}`
} else {
const last = types.pop()
message += `${types.join(', ')}, or ${last}`
}
errs.push(isOptional ? `${message} when given.` : `${message}.`);
}
var memoOptions;
function getOptions() {
if (memoOptions != null) return memoOptions;
const errs = [];
let notify;
require(errs, 'queries', opts.queries, ['array']);
for (const [search, operation] of opts.queries) {
require(errs, 'query[0]', search, ['string']);
require(errs, 'query[1]', operation, ['function']);
}
require(errs, 'notify', opts.notify, ['boolean', 'object'], true);
if (opts.notify != null) {
if (typeof opts.notify === 'object') {
const {email, header, subject, body} = opts.notify;
require(errs, 'notify.email', email, ['string'], true);
require(errs, 'notify.subject', subject, ['string'], true);
require(errs, 'notify.header', header, ['string'], true);
notify = {email, subject, body};
} else if (opts.notify === true) {
notify = {email: null, subject: null, body: null}
}
// If it's explicitly not present, we can assume it's valid
if (notify.email == null) {
notify.email = Session.getEffectiveUser().getEmail();
if (!notify.email) {
errs.push('Could not detect email - an explicit email is required.');
}
}
if (notify.subject == null) {
notify.subject = 'Weekly Filter Totals';
}
if (notify.body == null) {
notify.body =
'Number of threads successfully processed this past week: %c';
}
}
if (errs.length) {
throw new TypeError(`${errs.join('\n')}\n\n${invalidSuffix}`);
}
return memoOptions = {queries: opts.queries, notify: notify};
}
function init(name) {
const options = getOptions();
const properties = PropertiesService.getUserProperties();
let version = properties.getProperty('version');
if (version != null) {
version = +version;
} else if (properties.getProperty('total') != null) {
version = 0; // Let's phase in the old variant.
}
writeLine(name);
// The lock is needed to make sure the callbacks aren't executed while we
// are setting them up, tearing them down, or if we're sending the summary
// email. 60 minutes should be well more than enough to run.
const lock = LockService.getUserLock();
return {
options, properties, version,
log(str) { writeLine(`LOG: ${str}`); },
error(str) { writeLine(`ERROR: ${str}`); },
acquire(isPriority) {
const ms = 1000 /*ms*/ * 60 /*s*/ * (isPriority ? 10 : 60) /*min*/;
this.log('Waiting for lock...');
try {
lock.waitLock(ms);
return this;
} catch (_) {
if (isPriority) throw new Error('Failed to acquire lock.');
// A single lock failure isn't the end of the world here. The next
// scheduled run should be able to clean up after this.
this.error('Lock unable to be acquired. Skipping this run.');
this.error();
return;
}
},
release() { lock.releaseLock(); },
finish() {
this.log('Script executed successfully.');
writeLine();
},
};
}
__u = function uninstall() {
const state = init('Uninstalling script.').acquire(true);
try {
state.log('Deleting properties...');
state.properties.deleteAllProperties();
state.log('Removing old triggers...');
// Old trigger type
for (const trigger of ScriptApp.getProjectTriggers()) {
const name = trigger.getHandlerFunction();
state.log(`Removing trigger for function: ${name}`);
ScriptApp.deleteTrigger(trigger);
}
} finally {
state.release();
}
state.finish();
};
__i = function install() {
const state = init('Installing script.');
if (state.version != null && state.version > ScriptVersion) {
throw new Error(
'To downgrade, fully uninstall and then reinstall. Downgrading while ' +
'retaining old data is not supported.'
);
}
state.acquire(true);
try {
if (state.version != null) {
// Migrate if previously installed
state.log('Updating properties...');
state.properties.setProperty('version', ScriptVersion);
state.log('Updating triggers...');
// No triggers to migrate currently.
} else {
// Install from scratch
state.log('Installing properties...');
state.properties.setProperty('version', ScriptVersion);
state.properties.setProperty('total', '0');
state.log('Installing triggers...');
ScriptApp.newTrigger('__runQueries')
.timeBased()
.everyMinutes(10)
.create();
ScriptApp.newTrigger('__emailResults')
.timeBased()
.everyWeeks(1)
.onWeekDay(ScriptApp.WeekDay.MONDAY)
.atHour(6)
.create();
}
} finally {
state.release();
}
state.finish();
};
__r = function runQueries() {
const state = init('Running queries.');
if (state.version == null) {
throw new Error(
'Please install (or reinstall) this script so this task can run.'
);
}
for (const [query, callback] of state.options.queries) {
// If we can't acquire the lock, just return
if (!state.acquire(false)) return;
try {
state.log(`Executing query: ${query}`);
let total = +state.properties.getProperty('total');
try {
for (const thread of GmailApp.search(query)) {
const subject = thread.getFirstMessageSubject();
state.log(`Processing Gmail thread: ${subject}`);
total++;
callback(thread);
}
} finally {
state.properties.setProperty('total', `${total}`);
}
} finally {
state.release();
}
}
state.finish();
};
__e = function emailResults() {
const state = init('Emailing results.');
if (state.version == null) {
throw new Error(
'Please install (or reinstall) this script so this task can run.'
);
}
const notify = state.options.notify;
if (notify == null) return;
state.acquire(true);
let total;
try {
state.log('Generating email...');
total = +state.properties.getProperty('total');
state.log(`Previous total: ${total}`);
state.log('Resetting total...');
state.properties.setProperty('total', '0');
} finally {
state.release();
}
state.log('Sending weekly email...');
const body = notify.header.replace(/%c/g, total);
state.log(`Email: ${notify.email}`);
state.log(`Subject: ${notify.subject}`);
state.log(`Body: ${body}`);
GmailApp.sendEmail(notify.email, notify.subject, body);
state.log('Email sent successfully');
state.finish();
};
})();
@adamwolf
Copy link

Hi! I love this! For the Company example, I think you need to add:

        thread.addLabel(GmailApp.getUserLabelByName("Company"));

and maybe mention that the label has to be created already. I've done this on my fork, but I'm not sure if I can PR back or what.

@dead-claudia
Copy link
Author

Thanks! And I've updated it myself @adamwolf, so don't worry! (You can't file pull requests with gists, sadly.)

@adamwolf
Copy link

adamwolf commented Dec 31, 2019 via email

@secteurb
Copy link

Thanks for this script, it looks amazing!
However I have a installation problem I wasn't able to solve (on the vowel check), my Js skills are too limited:
"ReferenceError: first is not defined (ligne 95, fichier "Main")"
Do you have any idea of the way I could solve that ?

Best regards !

@dead-claudia
Copy link
Author

@secteurb Fixed. It was a bug in the script itself.

@secteurb
Copy link

secteurb commented Aug 20, 2020

Wow, that was fast! Thanks for your availability.
I'll keep on testing the script, thanks again!

@dead-claudia
Copy link
Author

@secteurb I happen to use this script personally, so any bug fixes also benefit me. (It stopped working and I hadn't noticed yet.) Also, I happen to maintain a much larger project on this site, so I'm pretty active here. 🙂

@Michele2795
Copy link

Good morning, I am recently approaching programming, I apologize if the questions I ask are stupid ...
I am trying to create a script to be able to automatically archive the mails I receive with a particular recipient (on my gmail account I receive mail from different email addresses) after I have read them. How can I do?
I had tried putting this script in the config file, but it doesn't do anything.
Thanks so much.

@dead-claudia
Copy link
Author

@Michele2795

Your __setup might look something akin to this:

__setup = {
    queries: [
        ["from:my.name@company.example", function (thread) {
            thread.moveToArchive();
        }],
    ],
    notify: {
        body: "%c threads from Company archived",
    },
};

@Prynka1995
Copy link

Prynka1995 commented Aug 30, 2021

Hi, how to fix it?

ReferenceError: __setup is not defined
@ Main.gs:67
@ Main.gs:335

@dead-claudia
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment