Merge pull request #509 from hildebrandttk/feat/unified-mailbox-account-scope

Feat/unified mailbox account scope

Rework the sidebar "All accounts" into an account-bounded "Unified Mailbox"
by default, with cross-account merging as an opt-in (admin-gated) sub-option.
The standalone per-account "All Mail" virtual folder is folded into the unified
All mail / Unread / Starred entries.

Conflict resolution notes:
- stores/settings-store.ts: both main and this branch independently added a
  per-account default-identity (#507) migration at different versions (main v6,
  branch v7). Merged migration is version 7 using the refactored migrateSettings
  function; the unified-mailbox rework is guarded at `version < 7` so users who
  stopped at main's interim v6 identity bump still receive it, while the #507
  identity-map coercion stays at `version < 6` so their populated map is kept.
- stores/auth-store.ts: kept main's applyPreferredIdentity (superset with the
  pre-#507 legacy migration).
- stores/email-store.ts: removed the ALL_MAIL_MAILBOX_ID paths (folded into the
  unified views) while preserving main's plugin hooks (onSearchResults /
  onEmailsFetched); adopted advancedSearchCrossViewEmails for advanced cross-view
  search.
- components/settings/layout-settings.tsx: kept main's faviconUnreadBadge setting
  alongside the new unifiedCrossAccount toggle.
- integration/: union-merged the two independently-authored suites - branch suite
  is authoritative (matches new behavior) with main's shared-identity (#569) group
  infrastructure preserved.
- components/email/email-composer.tsx: dropped a duplicate data-testid attribute
  introduced by the auto-merge.
This commit is contained in:
Linus Rath
2026-07-16 19:57:51 +02:00
62 changed files with 2460 additions and 461 deletions
+3 -3
View File
@@ -224,9 +224,9 @@ export interface IJMAPClient {
): Promise<{ blobId: string; size: number; type: string }>;
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer>;
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void>;
// ── Identities ────────────────────────────────────────────────
getIdentities(): Promise<Identity[]>;
+100 -29
View File
@@ -1968,6 +1968,13 @@ export class JMAPClient implements IJMAPClient {
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
// Mirror getEmails: emails fetched from a delegated/shared account carry
// bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line
// up with the namespaced ids the store holds for shared mailboxes. (#281 V3)
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return { emails, hasMore, total };
} catch (error) {
console.error('Search failed:', error);
@@ -2008,6 +2015,13 @@ export class JMAPClient implements IJMAPClient {
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
// Namespace shared/delegated-account mailbox ids (see searchEmails). The
// cross-account views (All mail / Unread / Starred) browse via this method,
// so without it shared emails would carry bare owner ids there. (#281 V3)
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return { emails, hasMore, total };
} catch (error) {
console.error('Advanced search failed:', error);
@@ -3448,8 +3462,8 @@ export class JMAPClient implements IJMAPClient {
return response.blob();
}
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
const blob = await this.fetchBlob(blobId, name, type);
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string> {
const blob = await this.fetchBlob(blobId, name, type, accountId);
return URL.createObjectURL(blob);
}
@@ -5728,8 +5742,8 @@ export class JMAPClient implements IJMAPClient {
return created as FileNode;
}
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const blob = await this.fetchBlob(blobId, name, type);
async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void> {
const blob = await this.fetchBlob(blobId, name, type, accountId);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -5742,6 +5756,7 @@ export class JMAPClient implements IJMAPClient {
}
private pollingInterval: NodeJS.Timeout | null = null;
private secondaryPollInterval: NodeJS.Timeout | null = null;
private pollingStates: { [key: string]: string } = {};
private sseAbortController: AbortController | null = null;
private sseReconnectTimeout: NodeJS.Timeout | null = null;
@@ -5759,6 +5774,10 @@ export class JMAPClient implements IJMAPClient {
};
private static readonly POLLING_INTERVAL = 3_000;
// Shared/secondary accounts get no SSE push (Stalwart pushes the primary
// account only), so poll them on a slow cadence alongside SSE to keep their
// folder + unified/All-Mail counters from going stale between focus events.
private static readonly SECONDARY_POLL_INTERVAL = 20_000;
private static readonly SSE_RECONNECT_DELAY = 3_000;
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
@@ -5766,13 +5785,34 @@ export class JMAPClient implements IJMAPClient {
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl) {
this.connectSSE(eventSourceUrl);
// SSE covers the primary account only; keep shared accounts fresh too.
this.startSecondaryAccountPoll();
} else {
// The fallback poll already covers every session account.
this.startPollingFallback();
}
this.setupBrowserEventListeners();
return true;
}
/**
* Slow poll of the session's shared/secondary accounts, run in parallel with
* SSE (which never reports them). Skipped when there are no shared accounts,
* and paused while the tab is hidden (visibilitychange forces a check on
* return). Reuses checkForStateChanges, which already reports per-account.
*/
private startSecondaryAccountPoll(): void {
if (this.secondaryPollInterval) return;
const hasSecondary = this.pollAccountIds().some((id) => id !== this.accountId);
if (!hasSecondary) return;
// Prime the per-account baseline so the first tick doesn't false-fire.
void this.fetchCurrentStates();
this.secondaryPollInterval = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return;
void this.checkForStateChanges();
}, JMAPClient.SECONDARY_POLL_INTERVAL);
}
private connectSSE(templateUrl: string): void {
if (this.isRateLimited()) {
this.scheduleSSEReconnect();
@@ -5899,12 +5939,30 @@ export class JMAPClient implements IJMAPClient {
}, JMAPClient.POLLING_INTERVAL);
}
/**
* Accounts whose Mailbox/Email state the poll should track. Stalwart's SSE
* only pushes StateChange for the primary account, never for delegated/shared
* (secondary) accounts, so their folder counters — and the unified/All-Mail
* badges that aggregate them — would otherwise never refresh from a background
* change. Polling every session account (primary + shared) closes that gap on
* the visibility/interval reconcile path. Mailbox/Email get callIds are tagged
* with the accountId (`mbx:<id>` / `eml:<id>`) so each account is compared
* independently. (#shared-counter-push)
*/
private pollAccountIds(): string[] {
const ids = Object.keys(this.accounts || {});
return ids.length > 0 ? ids : [this.accountId];
}
private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } {
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
const methodCalls: JMAPMethodCall[] = [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
];
const methodCalls: JMAPMethodCall[] = [];
for (const acctId of this.pollAccountIds()) {
methodCalls.push(
['Mailbox/get', { accountId: acctId, ids: null, properties: ['id'] }, `mbx:${acctId}`],
['Email/get', { accountId: acctId, ids: [], properties: ['id'] }, `eml:${acctId}`],
);
}
if (this.supportsCalendars()) {
using.push('urn:ietf:params:jmap:calendars');
@@ -5925,6 +5983,16 @@ export class JMAPClient implements IJMAPClient {
return { using, methodCalls };
}
/** Map a polled method response back to its (accountId, stateKey). */
private resolvePolledState(method: string, callId: unknown): { accountId: string; stateKey: string } | null {
if (typeof callId === 'string') {
if (callId.startsWith('mbx:')) return { accountId: callId.slice(4), stateKey: 'Mailbox' };
if (callId.startsWith('eml:')) return { accountId: callId.slice(4), stateKey: 'Email' };
}
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
return stateKey ? { accountId: this.accountId, stateKey } : null;
}
private async fetchCurrentStates(): Promise<void> {
if (this.isRateLimited()) {
return;
@@ -5939,10 +6007,10 @@ export class JMAPClient implements IJMAPClient {
if (response.ok) {
const data = await response.json();
for (const [method, result] of data.methodResponses) {
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
if (stateKey && result.state) {
this.pollingStates[stateKey] = result.state;
for (const [method, result, callId] of data.methodResponses) {
const resolved = this.resolvePolledState(method, callId);
if (resolved && result?.state) {
this.pollingStates[`${resolved.accountId}:${resolved.stateKey}`] = result.state;
}
}
}
@@ -5965,25 +6033,24 @@ export class JMAPClient implements IJMAPClient {
if (response.ok) {
const data = await response.json();
const changes: { [key: string]: string } = {};
let hasChanges = false;
// Build a per-account changed map so a background change in a shared
// (secondary) account is reported under its own accountId — which
// handleStateChange treats as "some mailbox changed" and refetches the
// full (own + delegated) mailbox list from.
const changedByAccount: Record<string, Record<string, string>> = {};
for (const [method, result] of data.methodResponses) {
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
if (stateKey && result.state) {
if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) {
changes[stateKey] = result.state;
hasChanges = true;
}
this.pollingStates[stateKey] = result.state;
for (const [method, result, callId] of data.methodResponses) {
const resolved = this.resolvePolledState(method, callId);
if (!resolved || !result?.state) continue;
const key = `${resolved.accountId}:${resolved.stateKey}`;
if (this.pollingStates[key] && this.pollingStates[key] !== result.state) {
(changedByAccount[resolved.accountId] ??= {})[resolved.stateKey] = result.state;
}
this.pollingStates[key] = result.state;
}
if (hasChanges && this.stateChangeCallback) {
this.stateChangeCallback({
'@type': 'StateChange',
changed: { [this.accountId]: changes },
});
if (Object.keys(changedByAccount).length > 0 && this.stateChangeCallback) {
this.stateChangeCallback({ '@type': 'StateChange', changed: changedByAccount });
}
}
} catch {
@@ -5996,6 +6063,10 @@ export class JMAPClient implements IJMAPClient {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
if (this.secondaryPollInterval) {
clearInterval(this.secondaryPollInterval);
this.secondaryPollInterval = null;
}
if (this.sseAbortController) {
this.sseAbortController.abort();
this.sseAbortController = null;
@@ -6162,8 +6233,8 @@ export class JMAPClient implements IJMAPClient {
// ── S/MIME raw-email helpers ─────────────────────────────────────
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
const response = await this.authenticatedFetch(url, {});
if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`);
+6 -13
View File
@@ -892,19 +892,12 @@ export function isUnifiedMailboxId(id: string): boolean {
}
/**
* Virtual mailbox id for the gated "All Mail" view: every folder of a single
* account merged into one date-sorted list. Distinct from the unified mailbox
* ids above, which merge one role across multiple accounts. Which folders are
* 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).
* Cross views shown in the unified ("Unified Mailbox") section: All mail /
* Unread / Starred. Each merges messages across the account boundary (the active
* account + its shared folders by default, or every logged-in account when the
* cross-account sub-option is on), narrowed by the user's folder selection (see
* `allMailFolderIds`). Distinct from the per-role unified ids (one role across
* accounts).
*/
export const CROSS_UNREAD = '__cross_unread__';
export const CROSS_STARRED = '__cross_starred__';