Created
March 19, 2020 01:44
-
-
Save Linerre/0a5ea08086b957fcd0ec7c9914b40bee to your computer and use it in GitHub Desktop.
Some Google Apps scripts to keep Gmail snappy
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
// Labels required: Review/Discard, Review/Keep, Kept | |
// Gmail's importance markers should be turned on | |
// Star a message to signal it's important | |
// (NOTE: messages will be automatically unstarred after treatment, e.g., after being auto-removed from Review/Discard) | |
/* tidyInbox.gs (run every 15 minutes) */ | |
function tidyInbox() { | |
// Apply label "Kept" to starred threads with label "Review/Keep" | |
label('Kept').addToThreads(find('is:starred label:Review/Keep')); | |
// Clear unwanted labels from starred threads | |
const REMOVE_IF_STARRED = ["Review/Discard", "Review/Keep"]; | |
for (var l=0; l<REMOVE_IF_STARRED.length; l++) { | |
label(REMOVE_IF_STARRED[l]).removeFromThreads(find('is:starred label:' + REMOVE_IF_STARRED[l])); | |
} | |
// If a message has two labels, allow one to take priority and remove the other | |
const OVERRIDING = ["Kept"] | |
const OVERRIDDEN = ["Review/Keep"]; | |
for (var l=0; l<OVERRIDDEN.length; l++) { | |
for (var m=0; m<OVERRIDING.length; m++) { | |
label(OVERRIDDEN[l]).removeFromThreads(find('label:' + OVERRIDDEN[l] + ' label:' + OVERRIDING[m])); | |
} | |
} | |
// Move any archived, trashed, or spammed threads with a star | |
// back to the inbox, make sure they're all marked important, and remove stars. | |
GmailApp.moveThreadsToInbox(find('is:starred (in:trash OR in:spam OR -label:inbox)')); | |
GmailApp.markThreadsImportant(find('is:starred -label:important')); | |
var starred = find('is:starred'); | |
for (var t=0; t<starred.length; t++) { | |
GmailApp.unstarMessages(starred[t].getMessages()); | |
} | |
// Archiving, trashing, or spam-marking should take precedence over marking important, | |
// so remove "important" markers from these threads. | |
GmailApp.markThreadsUnimportant(find('label:important (in:trash OR in:spam OR -label:inbox')); | |
// Archive all unimportant threads that have been read. | |
GmailApp.moveThreadsToArchive(find('label:read -label:important label:inbox')); | |
// Mark all archived threads as read. | |
GmailApp.markThreadsRead(find('label:unread -label:inbox')); | |
}; | |
/* markLargeThreads.gs (run every 24 hours) */ | |
function markLargeThreads() { | |
// GmailApp doesn't have a method for finding X threads with the greatest | |
// file size, so this is a binary search algorithm to do that. | |
function getXLargest(X, otherFilters) { | |
var minSize = 1; | |
var maxSize = 25000000; // Gmail's max attachment size is 25MB, but multiple | |
// large attachments may be present in one thread, so we raise the ceiling: | |
while (find(otherFilters + ' size:' + maxSize.toString()).length > 0) { maxSize *= 2; } | |
var midSize = Math.floor((minSize + maxSize)/2); | |
var threads = []; | |
while (threads.length != X && (maxSize>midSize && midSize>minSize)) { | |
// shouldLimit (below) is set to false: GmailApp's data limit of 100 threads | |
// doesn't apply to searches, only to changes, like adding a label or star | |
threads = find(otherFilters + ' size:' + midSize.toString(), shouldLimit=false); | |
if (threads.length > X) { | |
minSize = midSize; | |
midSize = Math.floor((minSize + maxSize)/2); | |
} else if (threads.length < X) { | |
maxSize = midSize; | |
midSize = Math.floor((minSize + maxSize)/2); | |
} | |
} | |
return threads; | |
} | |
// Get a bunch of large, old, unimportant emails and put them in one place. | |
// The batch will have twice as many threads as the number of new, UNimportant | |
// threads in the last 24 hours, max 100 (recommended to run once daily) | |
const REVIEW_DISCARD_LENGTH = find('newer_than:1d -label:inbox', batchLength=50).length * 2; | |
label("Review/Discard").addToThreads(getXLargest(REVIEW_DISCARD_LENGTH, '-label:inbox -in:trash')); | |
// Get a bunch of large, old, but marked-important emails and put them in one place. | |
// This batch will have twice as many threads as the number of new, important | |
// threads in the last 24 hours, max 100 (run daily) | |
const REVIEW_KEEP_LENGTH = find('newer_than:1d label:inbox', batchLength=50).length * 2; | |
label("Review/Keep").addToThreads(getXLargest(REVIEW_KEEP_LENGTH, 'label:inbox -label:Kept')); | |
} | |
/* findFrequentSenders.gs (run every 24 hours) */ | |
function findFrequentSenders() { | |
function isTimeUp(start) { | |
var now = new Date(); | |
return now.getTime() - start.getTime() > 300000; // 5 minutes | |
} | |
var filters = '-in:inbox'; | |
var froms = Object.create(null); | |
var batch = []; | |
var BATCH_SIZE = 500; | |
var index = 0; | |
var threadsFromThisPerson = []; | |
var scannedThreads = Object.create(null); | |
var draftBody = ""; | |
var start = new Date(); | |
timedBlock: | |
do { | |
batch = GmailApp.search(filters, index * BATCH_SIZE, BATCH_SIZE); | |
for (var t=0; t<batch.length; t++) { | |
if (isTimeUp(start)) { break timedBlock; } | |
var thread; | |
while (t<batch.length && scannedThreads[batch[t].getId()]) { | |
Logger.log("Skipping this thread! End of batch? " + !batch[t] + ". t=" + t); | |
t += 1; | |
} | |
if (t>=batch.length) { | |
Logger.log("Out of batch. Fetching new batch...index=" + index); | |
Logger.log("Object.keys(scannedThreads).length = " + Object.keys(scannedThreads).length); | |
break; | |
} | |
thread = batch[t]; | |
var messages = thread.getMessages(); | |
for (var m=0; m<messages.length; m++) { | |
var message = messages[m]; | |
var from = message.getFrom(); | |
if (!(from in froms)) { | |
threadsFromThisPerson = GmailApp.search(filters + " from:" + from); | |
froms[from] = Object.keys(threadsFromThisPerson).length; | |
for (var u=0; u<threadsFromThisPerson.length; u++) { | |
scannedThreads[threadsFromThisPerson[u].getId()] = true; | |
} | |
} | |
} | |
} | |
index += 1; | |
Logger.log("Reached end of do-while loop."); | |
} while (batch.length == BATCH_SIZE); | |
Logger.log("Exited do-while loop."); | |
if (isTimeUp(start)) { | |
draftBody += "Aborted to avoid execution time limit. " + (Object.keys(scannedThreads).length) + " threads were examined with a batch size of " + BATCH_SIZE + ".\n\n"; | |
} | |
var keys = Object.keys(froms); | |
var heap = new BinaryHeap(function ([key, value]) { return -value; }); | |
var newElement = []; | |
for (var i=0; i<keys.length; i++) { | |
newElement = [keys[i], froms[keys[i]]]; | |
heap.push(newElement); | |
} | |
var currentElement = []; | |
for (var k = 0; k<100; k++) { | |
currentElement = heap.pop(); | |
if (currentElement) { | |
draftBody += "#" + (k+1) + ": " + currentElement[0] + ": " + currentElement[1] + "\n"; | |
} | |
} | |
GmailApp.createDraft("[email protected]", "Your 100 most frequent senders from filter '" + filters + "'", draftBody).send(); | |
} | |
/* find.gs */ | |
function find(searchString, shouldLimit, batchLength) { | |
// The Gmail Service won't make changes to more than 100 threads | |
// at a time, so batchLength defaults to 100. | |
shouldLimit = (typeof shouldLimit !== 'undefined') ? shouldLimit : true; | |
batchLength = (typeof batchLength !== 'undefined') ? batchLength : 100; | |
if (shouldLimit) { | |
return GmailApp.search(searchString, 0, batchLength); | |
} else { | |
return GmailApp.search(searchString); | |
} | |
} | |
/* label.gs */ | |
function label(name) { | |
// This only works for user-defined labels, | |
// not system labels like "Spam." | |
return GmailApp.getUserLabelByName(name); | |
} | |
/* BinaryHeap.gs (source: http://eloquentjavascript.net/1st_edition/appendix2.html) */ | |
function BinaryHeap(scoreFunction){ | |
this.content = []; | |
this.scoreFunction = scoreFunction; | |
} | |
BinaryHeap.prototype = { | |
push: function(element) { | |
// Add the new element to the end of the array. | |
this.content.push(element); | |
// Allow it to bubble up. | |
this.bubbleUp(this.content.length - 1); | |
}, | |
pop: function() { | |
// Store the first element so we can return it later. | |
var result = this.content[0]; | |
// Get the element at the end of the array. | |
var end = this.content.pop(); | |
// If there are any elements left, put the end element at the | |
// start, and let it sink down. | |
if (this.content.length > 0) { | |
this.content[0] = end; | |
this.sinkDown(0); | |
} | |
return result; | |
}, | |
remove: function(node) { | |
var length = this.content.length; | |
// To remove a value, we must search through the array to find | |
// it. | |
for (var i = 0; i < length; i++) { | |
if (this.content[i] != node) continue; | |
// When it is found, the process seen in 'pop' is repeated | |
// to fill up the hole. | |
var end = this.content.pop(); | |
// If the element we popped was the one we needed to remove, | |
// we're done. | |
if (i == length - 1) break; | |
// Otherwise, we replace the removed element with the popped | |
// one, and allow it to float up or sink down as appropriate. | |
this.content[i] = end; | |
this.bubbleUp(i); | |
this.sinkDown(i); | |
break; | |
} | |
}, | |
size: function() { | |
return this.content.length; | |
}, | |
bubbleUp: function(n) { | |
// Fetch the element that has to be moved. | |
var element = this.content[n], score = this.scoreFunction(element); | |
// When at 0, an element can not go up any further. | |
while (n > 0) { | |
// Compute the parent element's index, and fetch it. | |
var parentN = Math.floor((n + 1) / 2) - 1, | |
parent = this.content[parentN]; | |
// If the parent has a lesser score, things are in order and we | |
// are done. | |
if (score >= this.scoreFunction(parent)) | |
break; | |
// Otherwise, swap the parent with the current element and | |
// continue. | |
this.content[parentN] = element; | |
this.content[n] = parent; | |
n = parentN; | |
} | |
}, | |
sinkDown: function(n) { | |
// Look up the target element and its score. | |
var length = this.content.length, | |
element = this.content[n], | |
elemScore = this.scoreFunction(element); | |
while(true) { | |
// Compute the indices of the child elements. | |
var child2N = (n + 1) * 2, child1N = child2N - 1; | |
// This is used to store the new position of the element, | |
// if any. | |
var swap = null; | |
// If the first child exists (is inside the array)... | |
if (child1N < length) { | |
// Look it up and compute its score. | |
var child1 = this.content[child1N], | |
child1Score = this.scoreFunction(child1); | |
// If the score is less than our element's, we need to swap. | |
if (child1Score < elemScore) | |
swap = child1N; | |
} | |
// Do the same checks for the other child. | |
if (child2N < length) { | |
var child2 = this.content[child2N], | |
child2Score = this.scoreFunction(child2); | |
if (child2Score < (swap == null ? elemScore : child1Score)) | |
swap = child2N; | |
} | |
// No need to swap further, we are done. | |
if (swap == null) break; | |
// Otherwise, swap and continue. | |
this.content[n] = this.content[swap]; | |
this.content[swap] = element; | |
n = swap; | |
} | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment