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:
@@ -397,4 +397,53 @@ describe('JMAPClient resilience', () => {
|
||||
).rejects.toThrow('Failed to fetch blob: 404');
|
||||
});
|
||||
});
|
||||
|
||||
// #281 V3: every email fetch path must namespace mailboxIds for shared/
|
||||
// delegated accounts (`${ownerId}:${id}`) so they line up with the store's
|
||||
// namespaced shared-mailbox ids. searchEmails/advancedSearchEmails are the
|
||||
// cross-view (All mail / Unread / Starred) browse paths and previously did not.
|
||||
describe('shared-account mailboxId namespacing', () => {
|
||||
function queryAndGet(email: Record<string, unknown>) {
|
||||
return {
|
||||
methodResponses: [
|
||||
['Email/query', { total: 1, ids: ['e1'] }, '0'],
|
||||
['Email/get', { list: [email] }, '1'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('advancedSearchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
|
||||
const client = await createConnectedClient(); // primary acct-1
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
|
||||
);
|
||||
|
||||
const { emails } = await client.advancedSearchEmails({ inMailbox: 'owner-x:x-inbox' }, 'owner-x');
|
||||
|
||||
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
|
||||
expect(emails[0].mailboxIds['x-inbox']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('searchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
|
||||
const client = await createConnectedClient();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
|
||||
);
|
||||
|
||||
const { emails } = await client.searchEmails('hello', undefined, 'owner-x');
|
||||
|
||||
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
|
||||
});
|
||||
|
||||
it('leaves own-account mailboxIds untouched (no foreign accountId)', async () => {
|
||||
const client = await createConnectedClient(); // primary acct-1
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { inbox: true } })),
|
||||
);
|
||||
|
||||
const { emails } = await client.advancedSearchEmails({ inMailbox: 'inbox' });
|
||||
|
||||
expect(emails[0].mailboxIds).toEqual({ inbox: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildCrossFilter,
|
||||
getCrossUnreadTotal,
|
||||
fetchCrossViewEmails,
|
||||
advancedSearchCrossViewEmails,
|
||||
resolveSourceFolderName,
|
||||
type UnifiedAccountClient,
|
||||
} from '@/lib/unified-mailbox';
|
||||
@@ -42,6 +43,26 @@ describe('getCrossIncludedMailboxes', () => {
|
||||
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
|
||||
expect(ids).toEqual(['inbox', 'projects']);
|
||||
});
|
||||
|
||||
it('honors an explicit crossIncludedMailboxIds selection (folder picker)', () => {
|
||||
const account = makeAccount({
|
||||
accountId: 'a',
|
||||
mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined), mb('archive', 'archive')],
|
||||
// user picked inbox + archive, excluded projects - overrides role exclusion
|
||||
crossIncludedMailboxIds: ['inbox', 'archive'],
|
||||
});
|
||||
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
|
||||
expect(ids).toEqual(['inbox', 'archive']);
|
||||
});
|
||||
|
||||
it('an empty selection yields no folders', () => {
|
||||
const account = makeAccount({
|
||||
accountId: 'a',
|
||||
mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined)],
|
||||
crossIncludedMailboxIds: [],
|
||||
});
|
||||
expect(getCrossIncludedMailboxes(account)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCrossFilter', () => {
|
||||
@@ -86,6 +107,22 @@ describe('getCrossUnreadTotal', () => {
|
||||
});
|
||||
expect(getCrossUnreadTotal([a, b])).toBe(10);
|
||||
});
|
||||
|
||||
it('counts only the selected folders when crossIncludedMailboxIds is set; shared accounts stay unrestricted', () => {
|
||||
// personal account narrowed to inbox only (projects excluded by the picker)
|
||||
const personal = makeAccount({
|
||||
accountId: 'a',
|
||||
mailboxes: [mb('inbox', 'inbox', 3), mb('projects', undefined, 4)],
|
||||
crossIncludedMailboxIds: ['inbox'],
|
||||
});
|
||||
// shared account unrestricted -> role-exclusion default (inbox + custom)
|
||||
const shared = makeAccount({
|
||||
accountId: 'owner',
|
||||
isShared: true,
|
||||
mailboxes: [mb('ns:inbox', 'inbox', 5, 'orig-inbox'), mb('ns:team', undefined, 2, 'orig-team'), mb('ns:junk', 'junk', 9, 'orig-junk')],
|
||||
});
|
||||
expect(getCrossUnreadTotal([personal, shared])).toBe(3 + 5 + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSourceFolderName', () => {
|
||||
@@ -167,3 +204,28 @@ describe('fetchCrossViewEmails', () => {
|
||||
expect(result.errors.get('bad')).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancedSearchCrossViewEmails', () => {
|
||||
it('ANDs the advanced filter onto the cross-view membership', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
|
||||
|
||||
await advancedSearchCrossViewEmails([a], 'all', { hasKeyword: '$flagged' }, 50, 0);
|
||||
|
||||
const [filter] = advancedSearchEmails.mock.calls[0];
|
||||
expect(filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses only the membership filter when the extra filter is empty', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
|
||||
|
||||
await advancedSearchCrossViewEmails([a], 'all', {}, 50, 0);
|
||||
|
||||
const [filter] = advancedSearchEmails.mock.calls[0];
|
||||
expect(filter).toEqual({ inMailbox: 'inbox' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { migratePolicyUnifiedMailbox } from '../migrate';
|
||||
|
||||
// migratePolicyUnifiedMailbox reads ADMIN_CONFIG_DIR at call time (see paths.ts),
|
||||
// so each test points it at a fresh temp dir.
|
||||
let dir: string;
|
||||
const policyPath = () => path.join(dir, 'policy.json');
|
||||
const markerPath = () => path.join(dir, '.migrated-unified-mailbox');
|
||||
|
||||
const writePolicy = (features: Record<string, unknown>) =>
|
||||
writeFile(policyPath(), JSON.stringify({ features, restrictions: {} }, null, 2), 'utf-8');
|
||||
const readFeatures = async () =>
|
||||
JSON.parse(await readFile(policyPath(), 'utf-8')).features as Record<string, unknown>;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'bw-policy-'));
|
||||
process.env.ADMIN_CONFIG_DIR = dir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.ADMIN_CONFIG_DIR;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('migratePolicyUnifiedMailbox', () => {
|
||||
it('enables unifiedCrossAccountEnabled when a cross view was active', async () => {
|
||||
await writePolicy({ crossUnreadViewEnabled: true });
|
||||
await migratePolicyUnifiedMailbox();
|
||||
expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(true);
|
||||
expect(existsSync(markerPath())).toBe(true);
|
||||
});
|
||||
|
||||
it('does not enable it for a standalone All-Mail-only policy', async () => {
|
||||
await writePolicy({ allMailViewEnabled: true, crossUnreadViewEnabled: false, crossStarredViewEnabled: false, crossAllViewEnabled: false });
|
||||
await migratePolicyUnifiedMailbox();
|
||||
expect((await readFeatures()).unifiedCrossAccountEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a one-shot: a later admin disable survives a re-run', async () => {
|
||||
await writePolicy({ crossAllViewEnabled: true });
|
||||
await migratePolicyUnifiedMailbox();
|
||||
expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(true);
|
||||
|
||||
// Admin turns it back off; the marker is present, so re-running is a no-op.
|
||||
await writePolicy({ crossAllViewEnabled: true, unifiedCrossAccountEnabled: false });
|
||||
await migratePolicyUnifiedMailbox();
|
||||
expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('no policy.json: writes the marker and does not throw', async () => {
|
||||
await migratePolicyUnifiedMailbox();
|
||||
expect(existsSync(markerPath())).toBe(true);
|
||||
expect(existsSync(policyPath())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -33,12 +33,12 @@ class ConfigManager {
|
||||
this.adminConfig = await this.readJsonFile('config.json') || {};
|
||||
const policy = await this.readJsonFile('policy.json');
|
||||
if (policy) {
|
||||
this.policyCache = {
|
||||
this.policyCache = ConfigManager.normalizePolicy({
|
||||
...DEFAULT_POLICY,
|
||||
...policy,
|
||||
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||
};
|
||||
});
|
||||
} else {
|
||||
this.policyCache = { ...DEFAULT_POLICY };
|
||||
}
|
||||
@@ -166,15 +166,28 @@ class ConfigManager {
|
||||
*/
|
||||
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
||||
assertWritable('update settings policy');
|
||||
this.policyCache = {
|
||||
this.policyCache = ConfigManager.normalizePolicy({
|
||||
...DEFAULT_POLICY,
|
||||
...policy,
|
||||
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||
};
|
||||
});
|
||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates deprecated feature gates forward. The standalone "All Mail" view
|
||||
* (`allMailViewEnabled`) was folded into the unified "All mail" entry, so an
|
||||
* admin who enabled it keeps that entry available via `crossAllViewEnabled`.
|
||||
* Idempotent - safe to run on every load.
|
||||
*/
|
||||
private static normalizePolicy(policy: SettingsPolicy): SettingsPolicy {
|
||||
if (policy.features.allMailViewEnabled) {
|
||||
policy.features.crossAllViewEnabled = true;
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload config from disk (for manual file edits or multi-instance).
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { AdminConfigData, AdminStateData } from './types';
|
||||
|
||||
const MIGRATION_MARKER = '.migrated-v2';
|
||||
const POLICY_UNIFIED_MARKER = '.migrated-unified-mailbox';
|
||||
|
||||
interface LegacyAdminData {
|
||||
passwordHash: string;
|
||||
@@ -59,6 +60,65 @@ export async function migrateLegacyAdminLayout(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot policy migration for the Unified Mailbox rework. Before it, the
|
||||
* cross views (crossUnread/crossStarred/crossAll) merged across every logged-in
|
||||
* account, so an admin who had any of them enabled was already permitting
|
||||
* cross-account aggregation. The new `unifiedCrossAccountEnabled` gate (default
|
||||
* false) controls that capability, so enable it whenever a cross view was active
|
||||
* - otherwise existing cross-account installs would silently lose the behaviour
|
||||
* on upgrade (the per-user `unifiedCrossAccount` is AND-ed with this gate).
|
||||
*
|
||||
* Persisted + marker-guarded (not a per-load normalization) so a later admin
|
||||
* decision to disable the gate survives restarts. Skipped on read-only config
|
||||
* dirs - operators who locked their config must migrate manually (mirrors
|
||||
* migrateLegacyAdminLayout). The deprecated `allMailViewEnabled` (a single-account
|
||||
* view, never cross-account) deliberately does NOT trigger this.
|
||||
*/
|
||||
export async function migratePolicyUnifiedMailbox(): Promise<void> {
|
||||
if (isConfigReadOnly()) return;
|
||||
|
||||
const markerPath = getConfigPath(POLICY_UNIFIED_MARKER);
|
||||
if (existsSync(markerPath)) return;
|
||||
|
||||
try {
|
||||
const policyPath = getConfigPath('policy.json');
|
||||
if (existsSync(policyPath)) {
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(policyPath, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
logger.warn('policy.json is not valid JSON; skipping Unified Mailbox policy migration');
|
||||
}
|
||||
const features =
|
||||
parsed && typeof parsed.features === 'object' && parsed.features
|
||||
? (parsed.features as Record<string, unknown>)
|
||||
: null;
|
||||
if (features) {
|
||||
const hadCrossAccount = !!(
|
||||
features.crossUnreadViewEnabled ||
|
||||
features.crossStarredViewEnabled ||
|
||||
features.crossAllViewEnabled
|
||||
);
|
||||
if (hadCrossAccount && features.unifiedCrossAccountEnabled !== true) {
|
||||
features.unifiedCrossAccountEnabled = true;
|
||||
const tmp = policyPath + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8');
|
||||
await rename(tmp, policyPath);
|
||||
logger.info('Migrated policy: enabled unifiedCrossAccountEnabled (cross-account views were active)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ensureConfigDir();
|
||||
await writeFile(markerPath, new Date().toISOString(), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.warn('Unified Mailbox policy migration failed; will retry on next boot', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the existing admin.json carries timestamp fields (legacy mixed layout),
|
||||
* split them into admin-state.json and rewrite admin.json without them.
|
||||
|
||||
@@ -60,10 +60,12 @@ export interface FeatureGates {
|
||||
hoverActionsConfigEnabled: boolean;
|
||||
filesEnabled: boolean;
|
||||
contactsEnabled: boolean;
|
||||
/** @deprecated Folded into `crossAllViewEnabled`; normalized forward on policy load. */
|
||||
allMailViewEnabled: boolean;
|
||||
crossUnreadViewEnabled: boolean;
|
||||
crossStarredViewEnabled: boolean;
|
||||
crossAllViewEnabled: boolean;
|
||||
unifiedCrossAccountEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
@@ -89,6 +91,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
crossUnreadViewEnabled: false,
|
||||
crossStarredViewEnabled: false,
|
||||
crossAllViewEnabled: false,
|
||||
unifiedCrossAccountEnabled: false,
|
||||
};
|
||||
|
||||
export interface ThemePolicy {
|
||||
|
||||
@@ -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
@@ -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
@@ -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__';
|
||||
|
||||
+51
-3
@@ -22,6 +22,18 @@ export interface UnifiedAccountClient {
|
||||
// must use the mailbox's `originalId` and explicitly target this accountId
|
||||
// so the server routes to the owner's data.
|
||||
isShared?: boolean;
|
||||
// Store-side mailbox ids that make up THIS account's contribution to the
|
||||
// cross views (All mail / Unread / Starred). It is intentionally per-account,
|
||||
// not a global list: mailbox ids are account-scoped, so an id from one account
|
||||
// is meaningless in another. The effective folder set of a cross view is the
|
||||
// UNION across every account's entry (one UnifiedAccountClient per account),
|
||||
// i.e. the sum of the respective per-account selections.
|
||||
//
|
||||
// For personal accounts this is the user's folder selection
|
||||
// (`allMailFolderIds[accountId]`); shared/group accounts are not individually
|
||||
// configurable and leave this undefined. When undefined, getCrossIncludedMailboxes
|
||||
// falls back to the role-exclusion default (inbox + custom folders).
|
||||
crossIncludedMailboxIds?: string[];
|
||||
}
|
||||
|
||||
export interface UnifiedFetchResult {
|
||||
@@ -49,7 +61,12 @@ const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
|
||||
*/
|
||||
export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined {
|
||||
for (const m of mailboxes) {
|
||||
if (email.mailboxIds?.[m.originalId ?? m.id]) return m.name;
|
||||
// All fetch paths now namespace shared emails' mailboxIds to the store id
|
||||
// (`${ownerId}:${origId}`), so matching `m.id` works for own and shared
|
||||
// alike. The `originalId` check stays as a defensive fallback for any email
|
||||
// that still carries a bare owner id. (#281 V3)
|
||||
if (email.mailboxIds?.[m.id]) return m.name;
|
||||
if (m.originalId && email.mailboxIds?.[m.originalId]) return m.name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -313,10 +330,18 @@ export function fetchUnifiedMailboxCounts(
|
||||
// filter is built from each account's included-mailbox ids.
|
||||
|
||||
/**
|
||||
* Mailboxes of an account included in the cross-account views: everything whose
|
||||
* role is not excluded (inbox + custom/no-role folders).
|
||||
* Mailboxes of an account included in the cross views (All mail / Unread /
|
||||
* Starred). When the account carries an explicit `crossIncludedMailboxIds`
|
||||
* selection (personal accounts honor the user's folder picker, shared accounts
|
||||
* include everything), only those mailboxes are used. Otherwise it falls back
|
||||
* to the role-exclusion default: everything whose role is not excluded (inbox +
|
||||
* custom/no-role folders).
|
||||
*/
|
||||
export function getCrossIncludedMailboxes(account: UnifiedAccountClient): Mailbox[] {
|
||||
if (account.crossIncludedMailboxIds) {
|
||||
const selected = new Set(account.crossIncludedMailboxIds);
|
||||
return account.mailboxes.filter((m) => selected.has(m.id));
|
||||
}
|
||||
return account.mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? ''));
|
||||
}
|
||||
|
||||
@@ -445,6 +470,29 @@ export async function searchCrossViewEmails(
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `searchCrossViewEmails`, but applies an advanced filter (text + field
|
||||
* conditions from `buildJMAPFilter`, built WITHOUT an `inMailbox` clause) on top
|
||||
* of the cross-view membership. `extraFilter` may be empty ({}), in which case
|
||||
* only the membership filter is used (equivalent to a plain browse).
|
||||
*/
|
||||
export async function advancedSearchCrossViewEmails(
|
||||
accounts: UnifiedAccountClient[],
|
||||
view: CrossView,
|
||||
extraFilter: Record<string, unknown>,
|
||||
limit: number,
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
const hasExtra = Object.keys(extraFilter).length > 0;
|
||||
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => {
|
||||
const membership = buildCrossFilter(view, ids);
|
||||
const filter = hasExtra
|
||||
? { operator: 'AND', conditions: [membership, extraFilter] }
|
||||
: membership;
|
||||
return account.client.advancedSearchEmails(filter, jmapAccountId, limit, position);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of unified roles that exist in at least one account's
|
||||
* mailboxes.
|
||||
|
||||
Reference in New Issue
Block a user