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:
@@ -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
@@ -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
@@ -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',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user