Acting on a message or thread
Magimail acts on Gmail by clicking Gmail's own controls in the account's live view; it renders no
action UI of its own. The main window toolbar, a notification's buttons, a menu bar popover row and
a magimail://thread/<id> link all end at the same click inside the page.
Action set
Gmail's toolbar identifies its controls with an act attribute, verified against the live DOM:
act |
Control |
|---|---|
| 1 | Mark as read |
| 2 | Mark as unread |
| 7 | Archive |
| 8 | Move to Inbox |
| 9 | Report spam |
| 10 | Delete |
Compose carries no act number; it is the gh="cm" control.
MAIL_ACTION_SELECTORS in accounts-manager.ts maps Magimail's actions onto these: Archive
(act="7"), Delete (act="10"), Mark as read (act="1"), Mark as unread (act="2") and Compose
(gh="cm"). Move to Inbox and Report spam are Gmail controls Magimail does not click.
Each entry in MAIL_ACTION_SELECTORS is an ordered list, tried one at a time and required to be laid
out. Never pair an act selector with a label fallback in one comma-joined string:
querySelector returns the first match in document order, not in selector order, so a wrong act
wins over the label rather than falling through to it.
The thread toolbar carries no labels at all: no aria-label and no data-tooltip on the act
element, its ancestors or its descendants. The act number is the only thing that identifies a
control there, so the label fallbacks in MAIL_ACTION_SELECTORS only ever match in the message list.
Two dispatch functions
triggerMailAction(action, accountId?) clicks the control in the account's live view. It drives
Gmail's own toolbar, so it hits whatever is selected there. The main window toolbar uses it for
Archive, Delete, Compose and reply, because the selection on screen is exactly what those buttons
should act on. Reply, reply to all and forward are matched on visible text rather than an attribute:
Gmail renders them as span[role="link"] with no aria-label, no data-tooltip and no act, and
the one element that does carry aria-label="Reply" is never laid out.
performThreadAction(accountId, threadId, action) takes a thread id. It routes the account's view to
that thread, clicks the control in the thread toolbar, and routes back to where it interrupted. It is
the only way to act on a thread that is not the selection on screen. The popover row cannot use Gmail's
own toolbar for this reason: the popover names a thread, and the window may be showing another
account, another thread, or a search.
A notification's buttons, a magimail://thread/<id> link and tray.threadAction all call
performThreadAction. THREAD_ACTIONS names the set once for all of them (open, archive, trash,
mark read, mark unread), and TRIAGE_ACTIONS is that set without open. Only open asks to look at
something: it raises the window and changes the account on screen, while triage must not.
Where an action is triggered
Main window toolbar
The NSToolbar buttons call triggerMailAction, so they act on the current selection. Archive,
Delete and Mark Read stay enabled on a list view because they act on a list selection too, while
Print and Share are disabled without an open message and the reply group is enabled from what the
open thread actually renders.
A notification's action buttons
A banner carries three actions from UNUserNotificationCenter: Archive, Mark Read, and Delete, which
is marked destructive. Tapping the banner itself opens the thread. The helper sends notify.action
(or notify.click for the tap) with the account and message id, and the main process runs
performThreadAction.
A menu bar popover row
Right-clicking a row archives, trashes, marks read or marks unread without opening the thread, so the
three newsletters at the top of the feed can be cleared from the popover. Left-click still opens, and
the menu leads with that. The row sends tray.threadAction.
A magimail://thread/<id> link
?action= names what to do with the thread, defaulting to opening it. The same set a notification's
buttons offer is reachable without one, so a Focus Filter, a script or a Shortcut can triage a named
thread directly. The action is read before the window is raised, because whether the link is triage
decides whether to raise it at all.
Read state
Mark read clicks nothing in a thread. A thread offers no mark-as-read control, because opening the
thread is what marks it read; Gmail shows mark-as-unread (act="2") instead. A markRead on a
thread therefore routes there and stops: the route itself is the action. Marking unread rides on the
same fact from the other side, since the route marks the thread read whether it was or not, and
clicking act="2" is what puts it back.
Read and unread are a single toggle in the message list. Gmail renders both controls and lays out
only the one the selection's state calls for, so toggleReadState clicks whichever is on screen. The
popover row's menu works the same way: Archive, then whichever of Mark as Read and Mark as Unread the
row is not already in, then Delete under a divider.
Routing an account's view to a thread
performThreadAction routes the view to the thread before it clicks, and the route carries most of
the care.
Navigating by hash
Gmail's live URL carries a query (?pli=1) that the thread URL does not, so loadURL differs from
the current document by more than its fragment and reloads the page, which tears down any open
compose. Assigning location.hash in the page is always a same-document route, and Gmail merges the
two states itself: the hash becomes #inbox/<thread>?compose=<draft>, leaving a docked compose or an
inline reply untouched. Measured: text typed into either survives the route, including inside Gmail's
autosave debounce.
Detecting arrival
The Atom feed's legacy hex thread id cannot confirm arrival. Gmail resolves the id and rewrites the
hash to its own canonical form (#inbox/FMfcgz…) about 100ms later, so the id asked for stops
appearing in the URL at precisely the moment the thread appears on screen; a gate waiting for that id
never fires, times out, skips the action, and leaves the thread it opened marked read.
The URL alone is no better: it reads as a thread route the instant the hash is assigned, while the list is still on screen and the toolbar is still laid out, so a visible Archive button is no evidence the right thread is up, and acting on button visibility alone archives or deletes whatever the view was showing before.
Arrival is therefore a thread route plus a title that has moved, checked against the webContents in
the main process rather than inside the page, which lets it reuse isThreadUrl instead of restating
that pattern in injected script. routeToThread sets the hash and then waits inside the page for
document.title to leave what it was, through waitUntil. The thread id is deliberately not part of
the test. The title is re-baselined on each pass, so a render landing somewhere that is not a thread
waits for the next move rather than returning, and cannot spin, because every pass needs a fresh
title. A route that never lands performs no action and restores the previous hash. Settling is kept
separate and best-effort: a route that arrives and then keeps redrawing is still a route that
arrived, and failing it would report a thread the caller had reached as one it never got to.
Acting on an account that is not on screen
A suspended account view sits at about:blank without a running renderer. When an action targets a
sleeping account, performThreadAction loads its resumeUrl (or the Gmail login page) and awaits DOM
readiness ([role="main"] or [gh="cm"]) before routing. Once the action completes,
releaseWokenView re-arms the inactivity timer, so a view woken solely for background triage is
suspended again after the grace period.
Waking the renderer is not enough on its own. switchAccount detaches every view but the one on
screen, and updateLayout only sizes that one, so a background account's view has no parent and a
0x0 viewport. Gmail lays out for the size it is given: no thread toolbar is rendered into nothing,
the control the action clicks never exists, and even the route fails to land. For the length of the
action sizeForBackgroundAction puts the view in at the bottom of the stack with the same rect as
the account on screen, which covers it completely, then takes it out again. Placing it beside the
window instead does not work; bounds outside the parent are clipped back to nothing.
A thread already on screen
If the active view is already displaying the notification's thread, performThreadAction runs the
toolbar click directly: it does not assign location.hash and does not restore the previous hash.
Opening or marking read an already-visible thread returns immediately.
Which thread is on screen cannot be read from the URL alone, for the id rewrite
Detecting arrival covers. isShowingThread therefore asks the document as well,
where the rendered thread still carries data-legacy-thread-id and data-legacy-message-id. The DOM
alone is not enough either, because a message listed in the inbox carries those same attributes on
its row; the DOM only counts when the view is on a thread route to begin with.
Refusing to route over unsaved work
routeToThread verifies that the view is not holding unsaved work before mutating the hash. It
refuses to route if the URL is in settings (location.hash.startsWith('#settings')), if a modal
dialog is open ([role="dialog"]), or if any input, textarea, or contenteditable element has focus
or holds text.
Routing is refused whenever the view holds something a route would interrupt, and an open compose is
the common case rather than the exception, so a dropped press would make triage silently do nothing
whenever the user happens to be writing. performThreadAction queues the refused action instead,
retries every 5 seconds once the view is free, and gives up after 15 minutes. Opening is deliberately not held: it asks to look at something now, and raising the
window five minutes later over whatever the user moved on to is worse than not moving at all.
The guard only applies to routing. An action on the thread already displayed clicks its toolbar control directly, which navigates nowhere and so cannot disturb a compose; that is why archiving the thread you are reading works while you are writing a reply to it.
Confirming Gmail accepted the click
Restoring the previous hash immediately after clicking an action button aborts in-flight network
requests. clickThreadControl drives the toolbar via @magimail/core's DOM_DRIVE_PREAMBLE:
resolveOrdered finds the candidate button, and clickAndConfirm dispatches input events and waits
for evidence that Gmail accepted the mutation (the button is removed or hidden, or the combined text
of live regions changes from its pre-click snapshot). Once confirmed, waitUntil requires DOM
mutations to quieten for 250 ms before restoring the route. Gmail never confirming is logged rather
than passed over, because a wait that stops waiting looks exactly like one that succeeded.
The text is compared against a snapshot rather than tested for presence. Gmail keeps its live regions in the document permanently and the first of them is empty, so asking whether one exists, is laid out or holds text answers yes on an idle page, before anything has been clicked.
restorePreviousRoute then checks whether the document is still on the thread routed for the action.
If the user clicked to another message or switched folders while the action was running, their active
route is preserved. It identifies the thread the same way isShowingThread does, by the hash or the
ids in the DOM; comparing hashes alone reads Gmail's own rewrite as the user having moved, and
abandons every restore.
Serialising rapid actions
Quick successive clicks from Notification Center could race route changes against one another. An
action queue (mailActionQueue) drains actions in order with an inter-action tick, rather than
letting overlapping operations collide.
Popover triage feedback
Unlike every other row action, triage does not dismiss the popover. Triage comes in runs, and closing after each action would mean reopening it to reach the next row.
TrayModel.apply marks the row read in the helper's own cache as the action is sent, rather than
waiting for the poll that will confirm it. A poll is far enough away that a row left unread reads as
the click having missed. An archived or trashed row stays in the feed as read rather than
disappearing, which is what a thread dropping out of the unread-only Atom feed already does (see
atom feed dropout reconciliation); the list does not
reshuffle under the pointer mid-triage.
The count follows on its own. Acting on the thread routes the account's live view to it, and the
status item counts on the same terms as the account switcher: getEffectiveUnreadForAccount reads
Gmail's sidebar wherever the page is alive and falls back to the feed where it is not. A background
account is no exception, so triage from a hidden window lands straight away, and a thread marked read
in the main window counts on the same terms. tray.threadAction still nudges that account's poller
once the action has run, for the categories Gmail is not showing: the sidebar can only report the
rows it renders.
apply records the state as well as showing it, and setMessages re-applies the record over every
list that arrives:
sequenceDiagram
participant P as Popover
participant E as Electron
participant G as Gmail
P->>P: apply(markRead), row greys out
P->>E: tray.threadAction
E->>G: route the view to the thread
G-->>E: DOM unread count drops
E->>P: tray.update (new count)
P->>E: tray.getMessages
E-->>P: tray.messages (from the last poll, taken before the click)
Acting on a thread moves an unread count, a changed count pushes fresh tray data, and
MenuBarController.updateData answers that by asking for messages again while the popover is open.
Electron serves that from AtomFeedPoller.getRecentMessages, which holds the last poll's entries,
taken before Gmail had been told anything, and sendTrayMessages labels every one of them UNREAD,
because the feed carries nothing else.
A record settles as soon as the feed says the same thing: a thread marked read is settled once it
drops out, one marked unread once it is listed again. Anything still waiting after
TrayModel.localStateGrace, 30 seconds, is dropped anyway, so an action Gmail never applied stops
claiming to have worked.