Skip to content

Instantly share code, notes, and snippets.

@williamokano
Created July 20, 2026 21:40
Show Gist options
  • Select an option

  • Save williamokano/7b26bb74d910d465057b9dbe3097d91d to your computer and use it in GitHub Desktop.

Select an option

Save williamokano/7b26bb74d910d465057b9dbe3097d91d to your computer and use it in GitHub Desktop.
Auto upsert filters gmail

Gmail Filter Sync

Manage your Gmail filters as code. A single array in a Google Apps Script is the source of truth; running one function makes your Gmail account converge to it — true upsert semantics, which Gmail's native export/import XML cannot do (importing always appends and duplicates).

What it does

Running syncFilters():

  • creates filters that are defined in the script but missing in Gmail
  • deletes filters that exist in Gmail but are no longer defined (only when MANAGE_ALL = true)
  • leaves untouched filters that already match a definition
  • auto-creates labels, including nested paths like GitHub/My Projects (parents are created first)

The triage philosophy baked into the defaults

Don't try to define which mail is important — define which mail is noise, and route it out of the inbox into labels. Whatever remains in the inbox is unfiltered, meaning it's either a real human writing to you or a brand-new noise pattern that deserves a new filter entry. The inbox becomes your filter backlog.

The bundled defaults implement that for a typical developer account:

Filter Destination Notes
GitHub notifications from your repos GitHub/My Projects repo list defined once in MY_PROJECT_REPOS
All other GitHub notifications GitHub/Other exclusion list auto-generated from the same constant, so the two can never drift
Review-request notifications GitHub/Review Requested overlay label only; GitHub encodes the reason in the CC address (review_requested@, push@, mention@, ...)
Dependency-bump PRs (dependabot/renovate) GitHub/Dependencies matched by chore(deps) / build(deps) subjects
Slack channel notifications Slack/Channels channel mails have in #channel in the subject; DMs don't and stay in the inbox (a commented-out filter routes DMs too if you prefer)
Google Calendar notifications Calendar
Invites sent directly by people Calendar matched by the invite.ics attachment — language- and sender-independent
Internal tools / HR / admin HR & Admin placeholder domains — replace with your own
Security alerts & verification codes Security starred + marked important so time-sensitive codes stand out inside the label

Setup (one time, ~3 minutes)

  1. Go to https://script.google.com (logged in as the Gmail account you want to manage) and create a New project.
  2. Replace the contents of Code.gs with this repository's Code.gs.
  3. In the left sidebar, click Services (+), find Gmail API, and click Add. This enables the advanced Gmail service — without it the script fails with Gmail is not defined.
  4. In the toolbar dropdown, select the syncFilters function and click Run. Grant the authorization prompt (the script asks for Gmail settings and labels scopes; it runs entirely in your account, no third party involved).
  5. Open the execution log (Ctrl+Enter / View → Logs). The first run is a dry run: it prints the full plan (creates / deletes / unchanged) without touching anything.
  6. If the plan looks right, set DRY_RUN = false at the top of the script and Run again.

Verifying it worked

  • Gmail → Settings → Filters and Blocked Addresses should list every filter from the array.
  • Run syncFilters once more: the log should read Plan: 0 to create, 0 to delete, N unchanged — proof that convergence and change-detection work.

Day-to-day usage

Notice a new kind of noise in your inbox → add one entry to the FILTERS array (or add a repo name to MY_PROJECT_REPOS) → Run. Done.

Filters only apply to new incoming mail. To retro-apply a rule to existing messages, run the same query in Gmail's search box, select all, and apply the label / archive manually.

Optional: fully hands-off

Add a time-driven trigger (clock icon → Triggers → Add trigger → syncFilters, e.g. weekly) if you edit the script from multiple places and want Gmail to converge automatically. For most people, running manually after each edit is enough.

Configuration reference

DRY_RUN            true  = log the plan only, change nothing
MANAGE_ALL         true  = the script OWNS all filters: anything created
                           by hand in the Gmail UI is deleted on next sync
                   false = additive-only; the script never deletes
MY_PROJECT_REPOS   list of GitHub repo names treated as "yours"

Each entry in FILTERS supports:

query          Gmail search string ("Has the words" in the UI)
label          label to apply; "/" nests; auto-created if missing
archive        skip the inbox
markImportant  mark as important
star           star the message
neverSpam      never send to spam
markRead       mark as read on arrival

Any valid Gmail search syntax works in query: from:, to:, cc:, subject:, filename:, list:, has:attachment, OR, - negation, parentheses, quoted phrases, etc.

Migrating from existing filters

The script matches filters by exact criteria+action signature. Filters you created by hand or imported via XML usually store their conditions in separate fields (from/subject) rather than the query field, so they will NOT match the script's definitions and — with MANAGE_ALL = true — will be deleted and replaced on the first real run. That is normally exactly what you want, and the dry run shows you the full plan first. If you'd rather keep manual filters alongside, set MANAGE_ALL = false.

Troubleshooting

  • Gmail is not defined — you skipped step 3; add the Gmail API advanced service.
  • Cannot read properties of null (reading 'filter') — you're running an old version of the script; current Code.gs guards the empty-filter-list response.
  • A filter is recreated on every run — Gmail sometimes normalizes a query string on save (e.g. collapsing whitespace). Check the DELETE and CREATE log lines: if they differ only in formatting, adjust your definition to match Gmail's normalized form.
  • Labels created but mail not moving — filters never apply retroactively; see "Day-to-day usage" above.

Limits & notes

  • Gmail allows up to 1,000 filters per account; each filter's criteria can be long, so grouping senders with OR is preferred anyway.
  • Forwarding actions require pre-verified forwarding addresses and are not included in the defaults.
  • Everything runs inside your own Google account under your own authorization. No data leaves it.
/**
* Gmail Filter Sync — filters-as-code with upsert semantics.
*
* The FILTERS array below is the single source of truth for your Gmail
* filters. Running syncFilters() makes your account converge to it:
*
* - creates filters that are defined here but missing in Gmail
* - deletes filters that exist in Gmail but are no longer defined here
* - leaves matching filters untouched
* - auto-creates any labels (including nested "Parent/Child" paths)
*
* See README.md for setup instructions.
*
* ⚠ MANAGE_ALL = true means this script OWNS your filters: anything
* created by hand in the Gmail UI will be deleted on the next sync.
* Set it to false to make the script additive-only (never deletes).
*/
// ─────────────────────────── CONFIG ───────────────────────────
var DRY_RUN = true; // true = only log what would happen, change nothing
var MANAGE_ALL = true; // true = delete any Gmail filter not defined below
// GitHub repos whose notifications you want grouped as "My Projects".
// GitHub sends each repo's mail via <repo-name>@noreply.github.com,
// so just list the repo names here.
var MY_PROJECT_REPOS = [
'my-main-repo',
// 'another-repo',
];
var repoAddrs = MY_PROJECT_REPOS
.map(function (r) { return r + '@noreply.github.com'; })
.join(' OR ');
/**
* Each filter definition supports:
* query Gmail search string (= "Has the words" in the UI)
* label label to apply; "/" nests, labels are auto-created
* archive remove from inbox (skip inbox)
* markImportant mark as important
* star star the message
* neverSpam never send to spam
* markRead mark as read on arrival
*
* Philosophy: don't try to define "important" mail. Define the NOISE and
* route it out. Whatever remains in the inbox is unfiltered = either a
* real human writing to you, or a new noise pattern that deserves a new
* entry in this array.
*/
var FILTERS = [
// ── GitHub ──────────────────────────────────────────────────
{
// Notifications from repos you actively work on.
query: 'from:notifications@github.com to:(' + repoAddrs + ')',
label: 'GitHub/My Projects',
archive: true, neverSpam: true,
},
{
// Everything else from GitHub. Keep the -to:(...) list identical to
// the to:(...) list above — both are generated from MY_PROJECT_REPOS,
// so this stays in sync automatically.
query: 'from:notifications@github.com -to:(' + repoAddrs + ')',
label: 'GitHub/Other',
archive: true, neverSpam: true,
},
{
// Overlay label: GitHub encodes the notification reason in the CC
// address (review_requested@, push@, mention@, assign@, ...).
// This filter only labels — the two above decide the destination.
query: 'from:notifications@github.com cc:(review_requested@noreply.github.com)',
label: 'GitHub/Review Requested',
},
{
// Automated dependency-bump PRs (dependabot / renovate style).
query: 'from:notifications@github.com subject:("chore(deps)" OR "build(deps)")',
label: 'GitHub/Dependencies',
archive: true,
},
// ── Slack ───────────────────────────────────────────────────
{
// Channel notifications carry "in #channel-name" in the subject;
// direct messages don't — so DMs fall through and stay in the inbox.
query: 'from:(notification@slack.com OR notification@slack-mail.com) subject:("in #")',
label: 'Slack/Channels',
archive: true,
},
// Uncomment to also route Slack DMs out of the inbox:
// {
// query: 'from:(notification@slack.com OR notification@slack-mail.com) -subject:("in #")',
// label: 'Slack/DMs',
// archive: true, markImportant: true,
// },
// ── Calendar ────────────────────────────────────────────────
{
// Google Calendar's own notification senders.
query: 'from:(calendar-notification@google.com OR calendar-noreply@google.com)',
label: 'Calendar',
archive: true,
},
{
// Invites sent directly by people, matched by the .ics attachment —
// works regardless of sender or subject language. You can still RSVP
// from Google Calendar itself; the email is redundant.
query: 'filename:invite.ics',
label: 'Calendar',
archive: true,
},
// ── Internal tools / admin (replace with your own senders) ──
{
query: 'from:(hr-platform.example.com OR contracts.example.com OR payroll.example.com)',
label: 'HR & Admin',
archive: true, neverSpam: true, markImportant: true,
},
// ── Security (starred + important so codes stand out in the label) ──
{
query: 'from:accounts@1password.com OR subject:("Verification Code" OR "Security Alert")',
label: 'Security',
archive: true, neverSpam: true, markImportant: true, star: true,
},
];
// ──────────────────────── SYNC ENGINE ─────────────────────────
// You shouldn't need to edit anything below this line.
function syncFilters() {
var labelIdByName = loadLabels_();
// Build desired filter resources from the definitions above.
var desired = FILTERS.map(function (f) {
var addLabelIds = [];
var removeLabelIds = [];
if (f.label) addLabelIds.push(getOrCreateLabel_(f.label, labelIdByName));
if (f.markImportant) addLabelIds.push('IMPORTANT');
if (f.star) addLabelIds.push('STARRED');
if (f.archive) removeLabelIds.push('INBOX');
if (f.neverSpam) removeLabelIds.push('SPAM');
if (f.markRead) removeLabelIds.push('UNREAD');
var resource = { criteria: { query: f.query }, action: {} };
if (addLabelIds.length) resource.action.addLabelIds = addLabelIds;
if (removeLabelIds.length) resource.action.removeLabelIds = removeLabelIds;
return resource;
});
// Fetch existing filters. When the account has zero filters, the API
// returns an empty body which Apps Script surfaces as null — guard it.
var resp = Gmail.Users.Settings.Filters.list('me');
var existing = (resp && resp.filter) || [];
var desiredSigs = {};
desired.forEach(function (r) { desiredSigs[signature_(r)] = r; });
var existingSigs = {};
existing.forEach(function (r) { existingSigs[signature_(r)] = r; });
var toCreate = Object.keys(desiredSigs).filter(function (s) { return !existingSigs[s]; });
var toDelete = MANAGE_ALL
? Object.keys(existingSigs).filter(function (s) { return !desiredSigs[s]; })
: [];
var unchanged = Object.keys(desiredSigs).length - toCreate.length;
Logger.log('Plan: %s to create, %s to delete, %s unchanged.%s',
toCreate.length, toDelete.length, unchanged,
DRY_RUN ? ' (DRY RUN — nothing applied)' : '');
toCreate.forEach(function (sig) {
Logger.log('CREATE: ' + describe_(desiredSigs[sig]));
if (!DRY_RUN) Gmail.Users.Settings.Filters.create(desiredSigs[sig], 'me');
});
toDelete.forEach(function (sig) {
Logger.log('DELETE: ' + describe_(existingSigs[sig]));
if (!DRY_RUN) Gmail.Users.Settings.Filters.remove('me', existingSigs[sig].id);
});
Logger.log('Done.' + (DRY_RUN ? ' Set DRY_RUN = false and re-run to apply.' : ''));
}
// Canonical signature so identical filters compare equal regardless of
// field/array ordering or server-assigned ids.
function signature_(r) {
var c = r.criteria || {};
var a = r.action || {};
return JSON.stringify({
criteria: {
from: c.from || '', to: c.to || '', subject: c.subject || '',
query: c.query || '', negatedQuery: c.negatedQuery || '',
hasAttachment: !!c.hasAttachment,
},
add: (a.addLabelIds || []).slice().sort(),
remove: (a.removeLabelIds || []).slice().sort(),
forward: a.forward || '',
});
}
function describe_(r) {
var parts = [];
var c = r.criteria || {};
if (c.query) parts.push('query="' + c.query + '"');
if (c.from) parts.push('from=' + c.from);
if (c.subject) parts.push('subject=' + c.subject);
var a = r.action || {};
if (a.addLabelIds) parts.push('+[' + a.addLabelIds.join(',') + ']');
if (a.removeLabelIds) parts.push('-[' + a.removeLabelIds.join(',') + ']');
return parts.join(' ');
}
function loadLabels_() {
var map = {};
var resp = Gmail.Users.Labels.list('me');
var labels = (resp && resp.labels) || [];
labels.forEach(function (l) { map[l.name] = l.id; });
return map;
}
// Creates the label (and any missing parents in a nested path) and
// returns its id. Uses/updates the provided name→id cache.
function getOrCreateLabel_(name, cache) {
var segments = name.split('/');
var path = '';
var id = null;
segments.forEach(function (seg) {
path = path ? path + '/' + seg : seg;
if (cache[path]) { id = cache[path]; return; }
Logger.log('CREATE LABEL: ' + path + (DRY_RUN ? ' (dry run — using placeholder id)' : ''));
if (DRY_RUN) { cache[path] = 'DRYRUN_' + path; id = cache[path]; return; }
var created = Gmail.Users.Labels.create({
name: path,
labelListVisibility: 'labelShow',
messageListVisibility: 'show',
}, 'me');
cache[path] = created.id;
id = created.id;
});
return id;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment