Magic Apps

Documentation

macmagic.app

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.