A notification tapped with the app closed reopens it on a link that names
its mailbox (`?slot=`). The browser reconnects its logins one at a time,
so that mailbox is often not connected yet when the link is read: with ten
logins, one on the Gmail bridge came back at 3.3 s and the link was judged
at 1.8 s. It was declared unavailable - "sign in to that account to open
this link" - and the app stayed on whichever mailbox was active.
Wait for the login instead, and give up only when it is not one of this
browser's logins, its restore fails, or 20 s go by.
Tagging and pinning replaced the message's whole keyword set with a
copy built from the list row. When the row was stale - the mail had
been read or answered on another device since - the write cleared
`$seen` or `$answered` again, and removed tags were written as `false`,
which a JMAP keyword map may not hold.
Keyword writes now go out as `keywords/<name>` patches for just the
keywords that change, removals as `null`, through the call that
reports a refused update. The local row gets the same patch.
Clicking a new-mail notification opened `/mail/message/<id>` in
whichever account was active. With several accounts signed in, that
looked the id up in the wrong account: nothing opened, or a different
message did and was marked read.
The preview API now returns the cookie slot of the login that owns the
notified account, the service worker adds it to the permalink (or links
that account's Inbox for a group notification), and the app switches to
the login on that slot before opening the message.
Drafts were saved without In-Reply-To and References, and a draft
re-opens in compose mode, where the composer has no original message to
rebuild them from. A reply that was saved and re-opened, or sent and
brought back with Undo, therefore went out as a new conversation.
`createDraft` now stores the threading headers, the composer saves them
with every draft of a reply, and re-opening a draft carries them into
the composer for later saves and the send.
JMAP thread ids are only unique within their account - Stalwart hands out
per-account counters such as "b" - so the cross-account views merged
unrelated conversations that happened to share an id, and every
client-side thread map (expansion, cache, loading flag, counts,
virtualizer key) mixed them up the same way.
Give ThreadGroup an account-scoped `threadKey` derived from the email's
source stamps and key grouping and all thread state by it. `threadId`
stays the raw id for Thread/get, which #281 already routes to the owning
account. fetchThreadEmailCounts now asks each source account for its own
threads instead of one client for every id. Single-account views carry no
stamps, so their key is the bare id and nothing changes there.
On a shared, group or aliased mailbox every reply, reply-all and forward
went out as `identities[0]` - the account owner - whichever mailbox was
open. `autoSelectReplyIdentity` fused two behaviours: matching one of the
user's OWN identities, which never rewrites `From:`, and the domain
catch-all, which puts an address they have not configured into `From:`.
Only the first is safe for everyone, so it is now unconditional; the
rewrite stays behind the setting, which is what its description promises.
`findReplyIdentityId` already implemented that match and was referenced
only from tests.
`forward` is admitted to the match - it was excluded, so a forward from a
shared mailbox went out as the owner - but never takes the rewrite: a reply
continues a thread whose participants know the addressing, while a forward
introduces a rewritten From to a recipient the user just typed. Quick reply
matches own identities only, having no From row to show or correct a
rewrite, and now falls back to the primary's signature as the composer
does, so a shared-mailbox quick reply is still signed.
`findReplyIdentityId` now scans recipients in To, Cc, Bcc order rather than
scanning identities, and prefers an untagged identity over a differently
tagged sibling. Scanning identities let their order decide, and
`sortIdentities` puts the login's own address first - so `To: team@,
Cc: you@` replied as you, the case this is meant to fix.
Automatic selection is restricted to the active account: `composerClient`
follows the chosen identity, and a reply or forward still carries blobIds
only its own account's server can resolve. The dropdown still offers every
account's identities to pick by hand.
New messages are deliberately left gated: `composeFromAccountEmail` falls
back to `AccountEntry.email`, written once at first login, so resolving it
unconditionally would override a user-chosen default sender identity (#507).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After a reload the unified mailbox showed only the active account's mail;
other accounts appeared only once visited. Intermittent (~1 in 14), and
not a timeout - nothing on this path uses setTimeout/AbortSignal/race.
checkAuth awaits only the active account and restores the rest in a
fire-and-forget background loop, registering each client only after
connect() resolves. buildUnifiedAccountClients drops accounts with no
live client (`if (!c) continue`). And isConnected was persisted, so it
rehydrated stale-true: the account passed the isConnected filter while
failing the client lookup, and connectedAccountsSignature already looked
complete at mount so nothing re-fired - and the effects keyed on it only
recompute counts, never the list.
Clear isConnected/hasError on rehydrate so both the filter and the
signature describe this session, and refetch the open unified/cross list
when an account joins the connected set (growth only, not on first
render, not while a search owns the list). Extracted
connectedAccountsGrew so the membership check is testable without
rendering MailApp.
Verified the reset cannot block reconnection: checkAuth reads the full
account list and never filters on isConnected.
Fixes#950
Switching folders currently keeps an active search applied and re-runs
it in the newly selected folder — deliberate behavior from #553, where
users wanted the query to follow them across folders. Others expect the
opposite: clicking Inbox/Sent should drop the search and just browse the
folder.
Add an opt-in setting (Settings → email behavior) that reconciles both.
Default is off, so #553's search-follows-you behavior is unchanged for
everyone. When on, any folder click clears the text query and advanced
filters and browses the folder instead of re-running the search.
Applied uniformly across the four folder-entry paths in MailApp — normal
mailboxes, per-account (Pro sidebar) folders, unified views, and cross
views — via one helper so no path diverges.
A message with attachments showed a paperclip and nothing else, so the
only way to reach a file was to open the message, scroll to the bottom
and find it. The list rows never had the information to do better:
EMAIL_LIST_PROPERTIES asked for hasAttachment, a boolean.
Request attachments alongside it and render the files as chips under the
preview, each one opening that file directly. Chips honour the existing
mailAttachmentAction setting, so they preview or download exactly as the
viewer's attachment list does.
Inline parts are filtered out. A single Outlook message routinely carries
half a dozen cid: signature spacers of under 200 bytes; chipping those
would bury the one file the reader actually wants. Only parts that are
neither inline nor cid-referenced, and that carry a filename, are shown —
two at a time, with the remainder collapsed into a count.
Downloads resolve the blob source from the row's own message rather than
the selected one, so they stay correct in the unified inbox where each
row can belong to a different account.
Once a search runs the header field shows the active query, but the only
way back to the full list was to open the panel and clear it there.
Put a clear button at the trailing end of the field, shown only while a
query is applied. The field is now a container rather than one button,
since nesting a button inside a button is invalid markup — the label
area and the clear control are siblings.
The full-screen panel is fixed inset-0, so it sat on top of the very
list it filters: submitting a query worked, updated the list underneath,
and left the reader staring at an empty panel.
Close it on submit, on picking a contact suggestion, and on clear, so
the results it just produced are what you land on. The header field then
shows the active query rather than the placeholder, so a filtered list
does not look unexplained, and tapping it reopens the panel to refine.
SearchBox gains an autoFocus prop so the panel opens straight to the
keyboard instead of needing a second tap.
Phones stacked two 56px bars: the mailbox title, then a row holding
select-all, the search box, the filter toggle and refresh. That is 112px
of chrome before the first message, and the title merely repeats what
the drawer already shows.
The header now carries the account avatar and a tappable search field,
and the second row is presented full-screen when that field is tapped —
so the same SearchBox, suggestions, history and filter chips appear,
none of it rebuilt. Select-all and refresh remain in that panel, and the
list already supports long-press selection and pull-to-refresh.
Tablet and desktop are unchanged; the row is only relocated on phones.
The mobile/tablet sidebar was pinned with physical direction utilities —
left-0 and -translate-x-full — which do not flip under dir="rtl". In
Hebrew the drawer therefore anchored to the left edge and slid in from
the left, away from the hamburger button that opens it.
Use the logical start-0 for the inset, and pair the physical translate
with an rtl: override, since translate-x has no logical equivalent. The
RTL rule is emitted after the LTR one, so it wins the cascade despite
Tailwind wrapping rtl: in a zero-specificity :where().