Last active
July 16, 2026 00:01
-
-
Save Excedrin/5ac9bf902e277187a405c3b9bc525306 to your computer and use it in GitHub Desktop.
Filter gmail messages using app script based on mail headers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // To deploy: paste into a new project at script.google.com, adjust the RULES, run | |
| // installTrigger() once, and approve the Gmail OAuth prompt. | |
| // ==== Debug ==== | |
| // Logs go to the Executions view (left sidebar in the Apps Script editor). | |
| var DEBUG_LOG = false; // note: JS lowercase false, not False | |
| // ==== Rules ==== | |
| // header -> value regex -> list of actions. | |
| // Regexes are unanchored (use ^...$ to match the whole value) and | |
| // case-insensitive. "." means "header is present with any value". | |
| // Each action is a function(thread, message). Add your own freely. | |
| var RULES = { | |
| "X-Custom-Header": { | |
| "some-custom-value": [label("Some Label")], | |
| ".": [label("Has custom header")], // header present, any value | |
| }, | |
| "List-Id": { | |
| "foo-users\\.lists\\.example\\.com": [label("Foo ML"), archive], | |
| }, | |
| "Auto-Submitted": { | |
| "^auto-generated$": [markRead], | |
| }, | |
| }; | |
| // ==== Action helpers ==== | |
| // label() is a factory returning an action; the rest are plain actions. | |
| function label(name) { | |
| return function (thread, message) { | |
| var l = GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name); | |
| thread.addLabel(l); // labels apply to threads, not messages | |
| }; | |
| } | |
| function archive(thread, message) { thread.moveToArchive(); } | |
| function markRead(thread, message) { message.markRead(); } | |
| function markImportant(thread, message) { thread.markImportant(); } | |
| function star(thread, message) { message.star(); } | |
| function trash(thread, message) { thread.moveToTrash(); } | |
| function forwardTo(address) { | |
| return function (thread, message) { message.forward(address); }; | |
| } | |
| // ==== Main entry point (run by trigger every 10 min) ==== | |
| function processInbox() { | |
| var props = PropertiesService.getScriptProperties(); | |
| var lastRun = Number(props.getProperty("lastRun")) || (Date.now() - 3600 * 1000); | |
| var now = Date.now(); | |
| // Gmail's after: only has second granularity; overlap by 5 min to be safe. | |
| // Actions should be idempotent (label/archive/markRead all are). | |
| var afterEpoch = Math.floor(lastRun / 1000) - 300; | |
| var query = "in:inbox after:" + afterEpoch; | |
| var threads = GmailApp.search(query); | |
| var stats = { examined: 0, skippedOld: 0, matched: 0, ruleHits: {} }; | |
| for (var i = 0; i < threads.length; i++) { | |
| var messages = threads[i].getMessages(); | |
| for (var j = 0; j < messages.length; j++) { | |
| // Skip messages older than the window (threads match if ANY message is new) | |
| if (messages[j].getDate().getTime() < afterEpoch * 1000) { | |
| stats.skippedOld++; | |
| continue; | |
| } | |
| stats.examined++; | |
| applyRules(threads[i], messages[j], stats); | |
| } | |
| } | |
| console.log( | |
| "%s messages examined (%s threads, %s old messages skipped), %s matched a rule. Window: %s -> %s (query: %s)", | |
| stats.examined, threads.length, stats.skippedOld, stats.matched, | |
| new Date(afterEpoch * 1000).toISOString(), new Date(now).toISOString(), query | |
| ); | |
| if (DEBUG_LOG) { | |
| for (var rule in stats.ruleHits) { | |
| console.log("rule [%s]: %s match(es)", rule, stats.ruleHits[rule]); | |
| } | |
| } | |
| props.setProperty("lastRun", String(now)); | |
| } | |
| function applyRules(thread, message, stats) { | |
| for (var headerName in RULES) { | |
| var headerValue = message.getHeader(headerName); | |
| if (!headerValue) { | |
| if (DEBUG_LOG) { | |
| console.log('msg "%s": header %s absent/empty', message.getSubject(), headerName); | |
| } | |
| continue; | |
| } | |
| var valueMap = RULES[headerName]; | |
| for (var pattern in valueMap) { | |
| var ruleKey = headerName + " ~ /" + pattern + "/"; | |
| if (new RegExp(pattern, "i").test(headerValue)) { | |
| stats.matched++; | |
| stats.ruleHits[ruleKey] = (stats.ruleHits[ruleKey] || 0) + 1; | |
| if (DEBUG_LOG) { | |
| console.log('msg "%s": %s MATCHED value "%s", running %s action(s)', | |
| message.getSubject(), ruleKey, headerValue, valueMap[pattern].length); | |
| } | |
| var actions = valueMap[pattern]; | |
| for (var k = 0; k < actions.length; k++) { | |
| actions[k](thread, message); | |
| } | |
| } else if (DEBUG_LOG) { | |
| console.log('msg "%s": %s no match (value: "%s")', | |
| message.getSubject(), ruleKey, headerValue); | |
| } | |
| } | |
| } | |
| } | |
| // ==== Run once manually to install the trigger ==== | |
| function installTrigger() { | |
| // Remove existing triggers for this function to avoid duplicates | |
| ScriptApp.getProjectTriggers().forEach(function (t) { | |
| if (t.getHandlerFunction() === "processInbox") ScriptApp.deleteTrigger(t); | |
| }); | |
| ScriptApp.newTrigger("processInbox").timeBased().everyMinutes(10).create(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment