Gmail Atom feed
Magimail reads unread counts and message snippets from Gmail's authenticated Atom XML feed over each
account's own session. This page covers the feed and the poller; what happens once an unread arrives
is in notifications.md.
Why the Atom feed
Magimail polls the Gmail Atom feed (https://mail.google.com/mail/feed/atom) from the Electron main
process, using each account's isolated partition session (persist:magimail_<accountId>). Background
sync then needs nothing outside the app: no OAuth client, no app password, no external service. The
project holds no OAuth client anywhere, which the Gmail API and IMAP IDLE would both require, and
which is the constraint that settles the choice; see
../shared/sessions-and-security.md.
Feed mechanics
Endpoint and authentication
- Default feed URL:
https://mail.google.com/mail/feed/atom - Label and category feeds:
- All unread:
https://mail.google.com/mail/feed/atom/unread - Important unread:
https://mail.google.com/mail/feed/atom/important - Primary category:
https://mail.google.com/mail/feed/atom/primary
- All unread:
Electron's session-scoped ses.fetch sends the partition's own cookies, so the request
authenticates itself and there is no token to hold:
import { session } from 'electron';
export async function fetchAtomFeed(accountId: string, category = ''): Promise<string> {
const ses = session.fromPartition(`persist:magimail_${accountId}`);
const url = category
? `https://mail.google.com/mail/feed/atom/${category}`
: 'https://mail.google.com/mail/feed/atom';
// ses.fetch, not net.fetch: the session *is* the receiver, which is what
// carries the partition's cookie jar.
const response = await ses.fetch(url, {
// The same identity the views present, so the feed is not a second browser
// (see ../shared/sessions-and-security.md).
headers: { 'User-Agent': DEFAULT_CLEAN_USER_AGENT },
});
if (!response.ok) {
throw new Error(`Atom feed HTTP ${response.status}`);
}
return response.text();
}
Payload structure
Gmail returns an XML document:
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://purl.org/atom/ns#" version="0.3">
<title>Gmail - Inbox for user@example.com</title>
<tagline>New messages in your Gmail inbox</tagline>
<fullcount>4</fullcount>
<link rel="alternate" href="https://mail.google.com/mail" type="text/html"/>
<modified>2026-09-05T11:40:00Z</modified>
<entry>
<title>Q3 Strategy Planning & Deck</title>
<summary>Hey team, please review the attached slides before our sync tomorrow...</summary>
<link rel="alternate" href="https://mail.google.com/mail?account_id=...&message_id=..." type="text/html"/>
<modified>2026-09-05T11:39:12Z</modified>
<issued>2026-09-05T11:39:12Z</issued>
<id>tag:gmail.google.com,2004:18c7294bfa201</id>
<author>
<name>Sarah Connor</name>
<email>sarah@cyberdyne.com</email>
</author>
</entry>
</feed>
Polling
Intervals and power awareness
- On mains power:
pollIntervalSecondsin settings sets the interval, and defaults to 60 seconds. - On battery:
pollIntervalBatterySecondsin settings sets the interval, and defaults to 180 seconds.powerMonitor.isOnBatteryPower()decides which of the two applies. - Hidden categories, window in front:
getEffectiveInterval()drops to 5s (10s on battery) when the window is focused and an active category is missing from the sidebar, so triage in that category still shows up quickly. Blurred, minimised, or with every active category in the sidebar, it uses the configured background interval. - Sleep and resume: the loop pauses on
powerMonitor.on('suspend')and fetches immediately onpowerMonitor.on('resume').
Sidebar observer and dual-source counts
Unread counts come from two sources: Gmail's own sidebar while the window is in front, and the Atom feed the rest of the time.
-
Foreground, real-time scalar badge. A scoped
MutationObserverinapps/magimail/src/preload.tswatches Gmail's left navigation sidebar (div[role="navigation"]) while the Gmail window is visible and active. It reads the numbers off child badge elements or ARIA labels for#inboxand#category/...(updates,promotions,social,forums), so triage updates the badge synchronously, with no network request. It dispatchesIPC_CHANNELS.DOM_UNREAD_UPDATEwith the category counts it found,presentCategories,hasCategoriesSectionandisLoaded. -
Category configuration banner. Magimail decides whether to show it once Gmail's mailbox shell has finished rendering (
isLoaded: true), so there is no timeout to guess at and no flicker while the page loads. Gmail unmounts a category from the DOM whenever it is not in the fixed top sidebar; disabling it in label settings and tucking it under "More labels" both do that. A category enabled in Magimail's settings can therefore be missing from the page entirely. In that state Magimail renders an AppKit-vibrant native banner across the top of the window: "For instant badge updates: show [Categories] in Settings & drag them above “More” in your sidebar. [Open Gmail Settings] [✕]". Clicking "Open Gmail Settings" navigates to#settings/labels. Dismissing hides it for the rest of the app session (dismissedBannersThisSession, held in memory), and the banner hides itself once the categories are visible in the sidebar. -
Background counts, snippets and fallback. The Atom feed is the authority for message snippets (
subject,summary,author), which the menu bar popover and the widgets display, and for unread counts while the window is minimised or the machine is asleep. Standard label paths (e.g./mail/feed/atom/promotions) map to custom user labels in Gmail and return<fullcount>0</fullcount>, so category feeds use Gmail's internal system smartlabels instead:- Promotions:
^smartlabel_promo(/mail/feed/atom/%5Esmartlabel_promo) - Social:
^smartlabel_social(/mail/feed/atom/%5Esmartlabel_social) - Updates:
^smartlabel_notification(/mail/feed/atom/%5Esmartlabel_notification) - Forums:
^smartlabel_group(/mail/feed/atom/%5Esmartlabel_group) - Primary:
^smartlabel_personal(/mail/feed/atom/%5Esmartlabel_personal)
When categories are folded away or hidden under "More labels",
AccountsManagermarks themabsentCategoriesand pulls their counts from the accelerated 5s in-focus poller. The count is the same either way; putting a category back in the sidebar only moves its updates from the 5-second poll to the synchronous DOM observer.Folding keeps the rows visible in the DOM. Gmail's fold deletes the category rows, and those rows are where the sidebar observer reads its counts. The preload swallows the click on the fold control and hides the rows in CSS instead, so the rows stay in the DOM and a folded section counts as fast as an open one. Gmail never records the fold, so the state is Magimail's, kept per account partition in
localStorage. Where this does not engage (a sidebar Gmail lays out differently, or a non-English UI, since the control has no role or label and is found by position), Gmail folds as it always did and the last known count below covers the number.Last known count per category.
AccountsManagerkeeps the last number it saw for each of the five categories, whichever source reported it, and falls back to that rather than to zero. Neither source covers all five on its own: the sidebar drops a category the instant it is folded away, and the poller fetches a sub-feed only when it has to. Without the remembered number, folding Categories would count every row inside it as zero until the first sub-poll came back, and the badge would drop to the Primary count and climb again seconds later.Anti-bounce rule. A completed Atom poll leaves the DOM counts for the categories present in the sidebar alone, so a stale edge-cached Atom response cannot overwrite what triage has just changed.
- Promotions:
Decoupled from the views
The underlying WebContentsView can be suspended or unloaded from RAM entirely, because requests go
through the partition session with ses.fetch() rather than through the view. Notifications and
badge counts keep updating while it is gone.
What a failed poll reports
A poll has three outcomes and AtomPollResult.status names which one, because two of them are
indistinguishable from good news if you only look at the number.
status |
Cause | What changes |
|---|---|---|
ok |
A feed parsed | Count, notifications, needsAuth cleared |
auth_required |
HTTP 401, or a 200 that is not a feed | needsAuth raised on the account; count held |
network_error |
Any other HTTP status, or fetch itself threw |
Nothing on the account; the tray is told it is offline |
A missing <fullcount> is not a count of zero. Gmail answers an expired session with a sign-in
page served as HTTP 200, so parseAtomXml returns fullCount: null when the tag is absent, and the
poller reads that as "this response is not a feed" rather than as a number.
Every failure path replays the last good count. The count is state; a poll that failed is an
absence of news, not news that the mail has gone. Reporting 0 on a dropped connection would empty
the badge and the widgets on every flaky network. lastReportedUnread is what a failed poll
re-sends.
The one place a missing count is still coerced to 0 is fetchCategoryCounts, which subtracts
off-category mail from the total: a sub-feed that failed should subtract nothing, and the total it is
subtracted from has already been validated.
auth_required is what raises needsAuth, which surfaces in three places (the toolbar segment's
warning symbol, the tray popover's sign-in banner, and the Settings account row's Sign In button), all
of which route back to switchAccount() so the user lands on the Gmail login for that partition.