DEV Community

TiltedLunar123
TiltedLunar123

Posted on

why element.click() does nothing on gmail's delete button

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()
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)