fix(email-store): route shared-folder batch actions to the owner account#657
Merged
rathlinus merged 1 commit intoJul 21, 2026
Conversation
Batch actions (delete, move, archive, mark-as-read) performed while viewing a shared/group mailbox directly from the "Shared" sidebar section were dispatched to the user's OWN account instead of the shared owner account. Emails in that view are undecorated (no sourceAccountId, that is only set in unified/cross-account views) and are reached through the active client, so they fell into the '__default__' bucket / non-unified else-branch, which defaults the JMAP accountId to the active account. batchArchive independently picked the archive folder from the merged mailbox list, where the user's own archive is listed first. Stalwart then applies Email/set to the wrong account: because the ids belong to the shared account it returns them as `updated: null` with an unchanged state (a silent no-op, not `notUpdated`), so the UI drops the rows optimistically and they reappear on the next reload. It only appears to work when the own and shared folder ids happen to collide. Add resolveViewAccountId() — the owner accountId of the directly-viewed shared folder (from the selected namespaced mailbox), undefined for a normal own-account view, mirroring fetchEmails and the single-email path. Route the four batch actions to that owner account (via the active client); batchMoveToMailbox also resolves the destination to its bare originalId, and batchArchive scopes the archive folder to that account. Own-account and unified/cross-account views are unchanged. Adds email-store-shared-folder-actions.test.ts covering all four batch actions in the non-unified shared view plus an own-account regression.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Batch actions performed while viewing a shared/group mailbox directly (the "Shared" sidebar section, not the unified inbox) were dispatched to the logged-in user's own account instead of the shared owner account. This affects:
batchDelete(multi-select delete / move-to-trash)batchMoveToMailbox(multi-select move)batchArchive(multi-select archive)batchMarkAsRead(multi-select mark read/unread)Symptom: the selected messages disappear from the list optimistically, then reappear after a page reload — the server change never persisted. No error is shown.
Why it happens
In the directly-viewed shared folder, the listed emails are undecorated — they carry no
sourceAccountId(that field is only set for unified/cross-account views). They are fetched from the owner account through the active login client, and only theirmailboxIdsare namespaced.Three of these grouped emails by
email.sourceAccountId || '__default__'; undecorated emails all fell into the'__default__'bucket, which routes to the active client withaccountId: undefined(i.e. the user's own account) and looks up the trash/destination in the user's own folder list.batchArchivehad the same effect for a different reason: it picked the archive folder from the merged mailbox list with no account scoping, and the user's own archive is listed first.Stalwart then receives an
Email/seton the wrong account. Because the ids belong to the shared account, it returns them underupdatedasnullwith an unchanged state (a silent no-op, notnotUpdated), so the client'snotUpdatedguard never fires and the UI drops the rows optimistically. On reload they are still there.This only appears to work when the user's own trash/destination id happens to collide with the shared account's (folder ids are per-account and often line up after folder canonicalization), which masks the bug on some accounts.
The fix
Added
resolveViewAccountId(): the owner JMAP accountId of the shared folder currently being viewed (derived from the selected namespaced mailbox), orundefinedfor a normal own-account view. This mirrors whatfetchEmailsand the single-email action path (resolveEmailActionContext) already do.The four batch actions now route undecorated emails to that owner account (reached via the active client) instead of
'__default__'→ own account.batchMoveToMailboxadditionally resolves the destination to its bareoriginalId(shared folders use namespaced store ids), andbatchArchivescopes the archive-folder lookup to that account.Own-account and unified/cross-account views are unchanged:
resolveViewAccountId()returnsundefinedwhen the selected folder isn't shared, and the existing per-sourceAccountIdgrouping still applies in unified view.Tests
New
stores/__tests__/email-store-shared-folder-actions.test.tscovers all four batch actions in the non-unified shared view (asserting they carry the owner accountId + the shared destination), plus an own-account regression guard (accountId staysundefined). Each case fails against the pre-fix code.npm run typecheckandnpm run lintpass.Scope / not included
This PR does not fix #387 ("can't empty the 'Deleted Items' folder for shared folders"). That report's symptom traces to a separate bug in the single-email delete path:
handleDeleteinapp/(main)/[locale]/page.tsxresolves the trash folder with!m.isSharedin the non-unified view, so a single delete from a shared folder targets the user's own Trash instead of the shared account's (it only appears to work when the trash ids collide). Because deleted mail then never lands in the shared account's real Trash, "empty Trash" on that folder has nothing to remove. That fix lives in the UI layer and is better handled as its own change; happy to follow up with it (and file a dedicated issue) separately.