feat: cross-account "All accounts" views + full group/shared-account support

Add cross-account aggregate mail views and make group/shared (delegated)
accounts first-class in every aggregate view. (The unified mailbox, the "All
Mail" view, and "include group inboxes" already exist on main; this branch adds
the cross-account views and the shared-account correctness work.)

New views (admin-gated + per-user toggle, nested under Unified Mailbox):
- Cross-account "All accounts": All unread / All starred / All mail across every
  connected account, including shared/group folders. Each list labels the source
  folder of every message.

Source reference on aggregated emails (the core of the shared-account work):
- Replace the overloaded `accountId` with two explicit, always-set fields:
  `sourceClientAccountId` (the login the mail is reachable through) and
  `sourceAccountId` (the owning JMAP account). `accountId` stays display-only.
- Resolution is branch-free everywhere: pick the client by sourceClientAccountId,
  pass sourceAccountId as the JMAP accountId (no-op for personal), read the
  owner's mailbox list cached by JMAP id. No capability scan.

Shared/group-account correctness across all aggregate views:
- Route open (click + auto-fetch), thread/conversation open + reply-refresh,
  mark read, star, move, delete (account-scoped trash), archive (owner-routed
  createMailbox / fetchAccountMailboxes), and spam + undo via the source ref.
- Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails /
  createMailbox where missing.
- Fix local unread/total counter math for shared folders via emailInMailbox()
  (matches namespaced shared ids and bare own ids).
- Keep the unified/cross virtual selection on background mailbox refresh (no
  jump back to inbox after deleting in All Drafts/Junk).

Junk UX:
- In "All Junk" the spam action becomes "not spam" in the viewer, context menu,
  and list hover icons; undo routes shared mail back to its own inbox.

Admin:
- Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching
  per-user toggle (allMailViewEnabled clarified too).

i18n / docs / tests:
- locales (19): cross-view labels + descriptions and hover not_spam, translated
  in all shipped languages.
- FEATURES.md + README.md document the new views and group-account support.
- Tests for shared-account routing (single + batch + undoSpam), decoration, and
  unified-selection preservation.
This commit is contained in:
Stefan Hildebrandt
2026-06-23 19:16:22 +02:00
parent 3dd596ba50
commit a29c33b50a
41 changed files with 1491 additions and 226 deletions
+4 -4
View File
@@ -73,7 +73,7 @@ export interface IJMAPClient {
// ── Mailboxes ─────────────────────────────────────────────────
getMailboxes(accountId?: string): Promise<Mailbox[]>;
getAllMailboxes(): Promise<Mailbox[]>;
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox>;
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
deleteMailbox(mailboxId: string): Promise<void>;
@@ -92,14 +92,14 @@ export interface IJMAPClient {
// ── Email mutations ───────────────────────────────────────────
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
toggleStar(emailId: string, starred: boolean): Promise<void>;
batchMarkAsRead(emailIds: string[], read?: boolean, accountId?: string): Promise<void>;
toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void>;
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
setKeyword(emailId: string, keyword: string): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string, accountId?: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>;
batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
+8 -8
View File
@@ -1273,19 +1273,19 @@ export class JMAPClient implements IJMAPClient {
]);
}
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
if (emailIds.length === 0) return;
const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }]));
await this.request([
["Email/set", { accountId: this.accountId, update: updates }, "0"],
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
}
async toggleStar(emailId: string, starred: boolean): Promise<void> {
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
accountId: accountId || this.accountId,
update: {
[emailId]: {
"keywords/$flagged": starred,
@@ -1391,12 +1391,12 @@ export class JMAPClient implements IJMAPClient {
]);
}
async batchDeleteEmails(emailIds: string[]): Promise<void> {
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
if (emailIds.length === 0) return;
await this.request([
["Email/set", {
accountId: this.accountId,
accountId: accountId || this.accountId,
destroy: emailIds,
}, "0"],
]);
@@ -1709,7 +1709,7 @@ export class JMAPClient implements IJMAPClient {
]);
}
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
async createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox> {
const createId = `new-${Date.now()}`;
const createData: Record<string, unknown> = { name };
if (parentId) {
@@ -1718,7 +1718,7 @@ export class JMAPClient implements IJMAPClient {
const response = await this.request([
["Mailbox/set", {
accountId: this.accountId,
accountId: accountId || this.accountId,
create: { [createId]: createData },
}, "0"],
]);
+54 -1
View File
@@ -58,9 +58,27 @@ export interface Email {
// S/MIME support
blobId?: string;
bodyStructure?: EmailBodyPart;
// Unified mailbox support - set when displaying emails from multiple accounts
// Unified mailbox support - set when displaying emails from multiple accounts.
// `accountId` is a DISPLAY-only reference (avatar color / label / badge) and may
// hold either an AccountEntry.id (personal) or the JMAP owner id (shared). For
// resolving the client + JMAP routing use the two dedicated fields below, which
// are always set on aggregated emails and unambiguous.
accountId?: string;
accountLabel?: string;
// AccountEntry.id of the logged-in client through which this email is reachable.
// Always a real login key → `useAuthStore.getClientForAccount(...)` resolves it.
// For personal sources this equals the account itself; for shared/group sources
// it is the delegating login (the shared account has no own login).
sourceClientAccountId?: string;
// JMAP account id of the email's owning account (personal: the client's primary;
// shared/group: the owner account). Always safe to pass as the JMAP `accountId`
// argument — equal to the client's primary for personal sources, so it is a no-op
// there, and triggers owner-scoped routing + mailbox-id namespacing for shared.
sourceAccountId?: string;
// Name of the email's originating folder, stamped for the aggregate "All …"
// views (All Mail, unified, cross-account) so the list can show where each
// message lives. Transient/client-only, not part of the JMAP object.
sourceFolder?: string;
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
scheduledSendAt?: string;
emailSubmissionId?: string;
@@ -879,3 +897,38 @@ export function isUnifiedMailboxId(id: string): boolean {
* included is a per-user setting (see `allMailFolderIds`).
*/
export const ALL_MAIL_MAILBOX_ID = '__all_mail__';
/**
* Cross-account "All …" views shown in the unified ("All accounts") section.
* Each merges messages across EVERY account (including shared/group folders),
* spanning all folders except junk/spam, sent, archive, trash and drafts, in
* one date-sorted list. Distinct from the per-role unified ids (one role across
* accounts) and from ALL_MAIL_MAILBOX_ID (all folders of a single account).
*/
export const CROSS_UNREAD = '__cross_unread__';
export const CROSS_STARRED = '__cross_starred__';
export const CROSS_ALL = '__cross_all__';
export type CrossView = 'unread' | 'starred' | 'all';
export const CROSS_VIEW_IDS: Record<CrossView, string> = {
unread: CROSS_UNREAD,
starred: CROSS_STARRED,
all: CROSS_ALL,
};
export const CROSS_VIEW_BY_ID: Record<string, CrossView> = Object.fromEntries(
Object.entries(CROSS_VIEW_IDS).map(([view, id]) => [id, view as CrossView])
) as Record<string, CrossView>;
export function isCrossViewId(id: string): boolean {
return id in CROSS_VIEW_BY_ID;
}
/**
* Mailbox roles excluded from the cross-account views. Everything else (inbox
* and custom/no-role folders) is included.
*/
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
'junk', 'sent', 'archive', 'trash', 'drafts',
]);