i have a chrome extension that bulk-deletes old gmail. promos, social updates, the giant attachments from 2022 nobody needs. it drives the gmail tab the same way you would by hand: run a search, select all, hit delete. no api, no oauth, everything stays in the browser.
it worked fine for about a year. then one day deletion just stopped. no error. the script would select 2,000 promos, "click" delete, and nothing happened. the messages sat there.
took me longer than i want to admit to find it.
the part that bit me
my select-all logic was fine. the rows highlighted, gmail's banner said "2000 selected." the problem was the delete button. i was calling it like a normal person would:
deleteButton.click()
that does nothing on gmail. not an error, not a warning. it just doesn't fire the handler. gmail's ui is built on google's Closure library and those controls don't listen for a plain synthetic click. they want the real pointer-event sequence a physical mouse produces.
so instead of one click() i had to dispatch the whole chain:
function realClick(el) {
const r = el.getBoundingClientRect()
const opts = { bubbles: true, cancelable: true, clientX: r.x + 2, clientY: r.y + 2 }
el.dispatchEvent(new PointerEvent('pointerdown', opts))
el.dispatchEvent(new MouseEvent('mousedown', opts))
el.dispatchEvent(new PointerEvent('pointerup', opts))
el.dispatchEvent(new MouseEvent('mouseup', opts))
el.dispatchEvent(new MouseEvent('click', opts))
}
pointerdown, mousedown, pointerup, mouseup, click. in that order. once i did that, delete and archive fired every time. menu items needed a hover first, so before those i fire pointerover, mouseover, mouseenter, mousemove, then the sequence above.
obvious in hindsight. gmail isn't checking "was this clicked," it's reconstructing whether a human moused over the thing and pressed down on it.
then gmail moved the furniture
right after i fixed that, a different thing broke. my "label as" recovery step started hanging. it would wait, and wait, until the timeout.
turned out gmail had moved the "label as" control out of a dialog and into an overflow menu. my old code was sitting there waiting for an input field that no longer rendered. nothing was wrong with the click code by then. the element just wasn't where i parked it.
that's the actual hard part of automating gmail. the click mechanics you solve once. the layout moves whenever they ship.
so the selector logic can't be one brittle string. for the master checkbox i score candidates instead of trusting a class name:
function scoreCheckboxCandidate(el) {
let score = 0
if (inToolbarArea(el)) score += 10
if (/select/i.test(el.getAttribute('aria-label') || '')) score += 5
if (nearTopOfPage(el)) score += 2
if (insideMessageRow(el)) score -= 20 // a per-row checkbox, not the master
return score
}
sort, take the top one. and if the master click doesn't actually populate gmail's internal selection model (it sometimes silently doesn't), it falls back to walking every visible tr[role="row"] and clicking the unchecked ones one at a time. slower, but it works when the fast path doesn't.
the boring constants that mattered more than i expected
a few delays i tuned by watching it fail:
- 250ms after selecting before acting, or gmail hasn't registered the selection yet
- 650ms between query passes
- 800ms after a delete before reading the result
- exponential backoff on rate limits: multiplier 1.8, capped at 30s, 6 retries per pass before it gives up on that query
and a hard wall-clock budget of 5 minutes per query. if a search is so huge it can't drain in five minutes, the run abandons it and moves on instead of spinning forever. i learned that one after a category:promotions older_than:1y pass that would've gone all night.
the guardrail i'm most glad i wrote
custom rules let you type any gmail search, which means someone (me, testing) can type something that nukes mail they wanted to keep. the options screen validates rules, but custom rules bypass that validator, so i re-check at the moment of execution:
const DANGEROUS_QUERY_TOKENS = ['is:starred', 'is:important', 'label:starred', 'in:sent', 'in:drafts']
if (queryHasDangerousToken(trimmed)) {
debugLog('refusing dangerous custom rule')
return
}
belt and suspenders. and everything goes to trash, never a permanent delete, so gmail's own 30-day retention is the real safety net. if a rule is wrong you've got a month to notice.
what's still rough
the layout-change detection is just telemetry right now. it warns me in the console when gmail's selection classes shift, but it doesn't adapt on its own. i still go in and re-score by hand. a version that re-derives the selectors from that warning would be the real fix and i haven't built it.
the per-row fallback is also O(n) clicks. on a 2,000-row page that's slow and you can watch it happen. fine for a tool i run on myself, not something i'd hand to someone impatient.
repo's here if you want to read the held-together parts: https://github.com/TiltedLunar123/gmail-one-click-cleaner
it works. not pretty, but it works.
Top comments (0)