Merge branch 'main' into feature/scheduled-send

# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/layout/sidebar.tsx
#	stores/email-store.ts
#	stores/settings-store.ts
This commit is contained in:
Lucas Gaitzsch
2026-05-22 12:31:06 +02:00
155 changed files with 4702 additions and 869 deletions
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useContactStore } from '../contact-store';
import { useContactStore, getContactPhotoUri, normalizeContactPhotoUri } from '../contact-store';
import type { ContactCard } from '@/lib/jmap/types';
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' });
@@ -496,6 +496,46 @@ describe('contact-store', () => {
});
});
describe('normalizeContactPhotoUri', () => {
it('rewrites malformed data:base64,... URIs using the media mediaType', () => {
expect(normalizeContactPhotoUri('data:base64,AAAA', 'image/png'))
.toBe('data:image/png;base64,AAAA');
});
it('rewrites data:;base64,... URIs using the media mediaType', () => {
expect(normalizeContactPhotoUri('data:;base64,AAAA', 'image/gif'))
.toBe('data:image/gif;base64,AAAA');
});
it('defaults to image/jpeg when no mediaType is available', () => {
expect(normalizeContactPhotoUri('data:base64,AAAA'))
.toBe('data:image/jpeg;base64,AAAA');
});
it('leaves well-formed data URIs unchanged', () => {
const good = 'data:image/png;base64,AAAA';
expect(normalizeContactPhotoUri(good)).toBe(good);
});
it('leaves http(s) URIs unchanged', () => {
const url = 'https://example.com/photo.jpg';
expect(normalizeContactPhotoUri(url)).toBe(url);
});
});
describe('getContactPhotoUri', () => {
it('returns a normalized data URI for malformed Stalwart photos (#307)', () => {
const contact = makeContact({
media: { m0: { kind: 'photo', uri: 'data:base64,AAAA', mediaType: 'image/png' } },
});
expect(getContactPhotoUri(contact)).toBe('data:image/png;base64,AAAA');
});
it('returns undefined when no photo media is present', () => {
expect(getContactPhotoUri(makeContact())).toBeUndefined();
});
});
describe('persistence/partialize', () => {
it('should persist contacts when supportsSync is false', () => {
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import type { Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
return {
id: overrides.id ?? 'inbox',
name: overrides.name ?? 'Inbox',
sortOrder: 0,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
isSubscribed: true,
isShared: false,
...overrides,
};
}
describe('useEmailStore multi-account state', () => {
beforeEach(() => {
useEmailStore.setState({
accountMailboxes: {},
viewingAccountId: null,
selectedMailbox: '',
selectedEmail: null,
selectedEmailIds: new Set(),
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
});
});
it('caches mailboxes per account via setAccountMailboxes', () => {
const accountA = [makeMailbox({ id: 'a-inbox', name: 'A Inbox' })];
const accountB = [makeMailbox({ id: 'b-inbox', name: 'B Inbox' })];
useEmailStore.getState().setAccountMailboxes('account-a', accountA);
useEmailStore.getState().setAccountMailboxes('account-b', accountB);
expect(useEmailStore.getState().accountMailboxes).toEqual({
'account-a': accountA,
'account-b': accountB,
});
});
it('replaces the cached entry when setAccountMailboxes is called again', () => {
const initial = [makeMailbox({ id: 'a-inbox' })];
const updated = [makeMailbox({ id: 'a-inbox' }), makeMailbox({ id: 'a-sent', name: 'Sent' })];
useEmailStore.getState().setAccountMailboxes('account-a', initial);
useEmailStore.getState().setAccountMailboxes('account-a', updated);
expect(useEmailStore.getState().accountMailboxes['account-a']).toEqual(updated);
});
it('clearAccountMailboxes wipes the entire cache', () => {
useEmailStore.getState().setAccountMailboxes('account-a', [makeMailbox()]);
useEmailStore.getState().setAccountMailboxes('account-b', [makeMailbox()]);
useEmailStore.getState().clearAccountMailboxes();
expect(useEmailStore.getState().accountMailboxes).toEqual({});
});
it('setViewingAccount updates viewingAccountId without touching the mailbox cache', () => {
useEmailStore.getState().setAccountMailboxes('account-a', [makeMailbox()]);
useEmailStore.getState().setViewingAccount('account-a');
expect(useEmailStore.getState().viewingAccountId).toBe('account-a');
expect(useEmailStore.getState().accountMailboxes['account-a']).toBeDefined();
useEmailStore.getState().setViewingAccount(null);
expect(useEmailStore.getState().viewingAccountId).toBeNull();
});
it('selectAccountMailbox sets viewing and selected together, and clears email selection state', () => {
useEmailStore.setState({
selectedEmail: { id: 'e1' } as unknown as ReturnType<typeof useEmailStore.getState>['selectedEmail'],
selectedEmailIds: new Set(['e1', 'e2']),
selectedKeyword: 'work',
expandedThreadIds: new Set(['thread-1']),
});
useEmailStore.getState().selectAccountMailbox('account-b', 'b-inbox');
const state = useEmailStore.getState();
expect(state.viewingAccountId).toBe('account-b');
expect(state.selectedMailbox).toBe('b-inbox');
expect(state.selectedEmail).toBeNull();
expect(state.selectedEmailIds.size).toBe(0);
expect(state.selectedKeyword).toBeNull();
expect(state.expandedThreadIds.size).toBe(0);
});
it('selectAccountMailbox with null accountId switches back to the active account', () => {
useEmailStore.getState().selectAccountMailbox('account-b', 'b-inbox');
expect(useEmailStore.getState().viewingAccountId).toBe('account-b');
useEmailStore.getState().selectAccountMailbox(null, 'a-inbox');
expect(useEmailStore.getState().viewingAccountId).toBeNull();
expect(useEmailStore.getState().selectedMailbox).toBe('a-inbox');
});
it('fetchAccountMailboxes caches the result keyed by accountId', async () => {
const mailboxes = [makeMailbox({ id: 'a-inbox' }), makeMailbox({ id: 'a-sent', name: 'Sent' })];
const client = {
getMailboxes: vi.fn().mockResolvedValue(mailboxes),
} as unknown as IJMAPClient;
await useEmailStore.getState().fetchAccountMailboxes(client, 'account-a');
expect(client.getMailboxes).toHaveBeenCalledTimes(1);
expect(useEmailStore.getState().accountMailboxes['account-a']).toEqual(mailboxes);
});
it('fetchAccountMailboxes leaves the cache untouched when the client throws', async () => {
useEmailStore.getState().setAccountMailboxes('account-a', [makeMailbox({ id: 'a-inbox' })]);
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const client = {
getMailboxes: vi.fn().mockRejectedValue(new Error('boom')),
} as unknown as IJMAPClient;
await useEmailStore.getState().fetchAccountMailboxes(client, 'account-a');
expect(useEmailStore.getState().accountMailboxes['account-a']).toEqual([
makeMailbox({ id: 'a-inbox' }),
]);
consoleError.mockRestore();
});
});
+18
View File
@@ -43,6 +43,7 @@ interface AccountState {
setDefaultAccount: (accountId: string) => void;
getDefaultAccount: () => AccountEntry | null;
updateAccount: (accountId: string, updates: Partial<AccountEntry>) => void;
reorderAccounts: (orderedIds: string[]) => void;
getActiveAccount: () => AccountEntry | null;
getAccountById: (accountId: string) => AccountEntry | undefined;
getNextCookieSlot: () => number;
@@ -168,6 +169,23 @@ export const useAccountStore = create<AccountState>()(
}));
},
reorderAccounts: (orderedIds) => {
set((s) => {
const byId = new Map(s.accounts.map((a) => [a.id, a]));
const reordered: AccountEntry[] = [];
for (const id of orderedIds) {
const a = byId.get(id);
if (a) {
reordered.push(a);
byId.delete(id);
}
}
// Append any accounts that weren't in the ordered list (defensive)
for (const a of byId.values()) reordered.push(a);
return { accounts: reordered };
});
},
getActiveAccount: () => {
const state = get();
return state.accounts.find((a) => a.id === state.activeAccountId) ?? null;
+6 -1
View File
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
import { JMAPClient, RateLimitError } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useIdentityStore } from './identity-store';
import { setClientLookup } from './client-registry';
import { useContactStore } from './contact-store';
import { useVacationStore } from './vacation-store';
import { useCalendarStore } from './calendar-store';
@@ -1255,7 +1256,7 @@ export const useAuthStore = create<AuthState>()(
return;
}
// Orphan-cookie adoption when no accounts are registered but a
// Orphan-cookie adoption - when no accounts are registered but a
// basic-auth session cookie is present (set by /api/auth/impersonate
// or by another server-side hand-off), promote it into the account
// registry so the normal restoration path picks it up. Without this
@@ -1661,3 +1662,7 @@ export const useAuthStore = create<AuthState>()(
}
)
);
// Expose getClientForAccount to the calendar/contact stores via a small
// shared registry - see [[stores/client-registry]] for rationale.
setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId));
+185 -14
View File
@@ -10,6 +10,36 @@ import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { generateUUID } from '@/lib/utils';
import { apiFetch } from '@/lib/browser-navigation';
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
import { getClientByLocalAccountId } from './client-registry';
/**
* When the Pro shell aggregates calendars/events from every connected
* account, the entity carries a `localAccountId` pointing back to the
* owning JMAP client. Mutations need to use *that* client - the active
* client (passed in by the page) could be on a different server entirely.
* Falls back to the active client when `localAccountId` is unset or no
* matching client is registered.
*
* Lookup goes through `client-registry` (not a direct auth-store import)
* to avoid a top-level cycle: auth-store already imports this module to
* bootstrap feature stores after login.
*/
function resolveAccountClient<T extends IJMAPClient>(active: T, localAccountId?: string): T {
if (!localAccountId) return active;
const lookup = getClientByLocalAccountId(localAccountId) as T | undefined;
return lookup ?? active;
}
/**
* Strip the local-account namespace prefix from an id (if present). Used
* before passing ids back to a JMAP client, since the prefix only exists
* to keep multi-account ids unique inside the client-side store.
*/
function stripLocalAccountPrefix(id: string, localAccountId?: string): string {
if (!localAccountId) return id;
const prefix = `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`;
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
}
// In-flight refresh dedup. Concurrent callers (auto-interval +
// manual refresh, two account-switch reloads, etc.) share the same
@@ -24,6 +54,59 @@ export function isCalendarViewMode(value: unknown): value is CalendarViewMode {
return typeof value === 'string' && CALENDAR_VIEW_MODES.includes(value as CalendarViewMode);
}
/**
* Prefix used to namespace calendar/event IDs that belong to a non-active
* JMAP account when the Pro shell aggregates across accounts. The active
* account's IDs are left untouched so existing single-account code paths
* (links, deep-links, JMAP mutations) keep working unchanged.
*/
const CROSS_ACCOUNT_ID_DELIMITER = '::';
function buildCrossAccountIdPrefix(localAccountId: string): string {
return `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`;
}
function prefixCalendarsWithLocalAccount(
calendars: Calendar[],
localAccountId: string,
isActiveAccount: boolean,
): Calendar[] {
if (isActiveAccount) {
return calendars.map((cal) => ({ ...cal, localAccountId }));
}
const prefix = buildCrossAccountIdPrefix(localAccountId);
// Preserve each calendar's original `isShared` flag - it distinguishes
// the user's own calendars on the other account from calendars shared
// *into* that account by yet another user. The sidebar uses this split
// to render "My Calendars" vs "Shared" sub-sections per account.
return calendars.map((cal) => ({
...cal,
id: `${prefix}${cal.id}`,
localAccountId,
}));
}
function prefixEventsWithLocalAccount(
events: CalendarEvent[],
localAccountId: string,
isActiveAccount: boolean,
): CalendarEvent[] {
if (isActiveAccount) {
return events.map((event) => ({ ...event, localAccountId }));
}
const prefix = buildCrossAccountIdPrefix(localAccountId);
return events.map((event) => ({
...event,
id: `${prefix}${event.id}`,
localAccountId,
calendarIds: event.calendarIds
? Object.fromEntries(
Object.entries(event.calendarIds).map(([calId, v]) => [`${prefix}${calId}`, v]),
)
: event.calendarIds,
}));
}
function mapCalendarIdsToStoreIds(
calendarIds: Record<string, boolean> | undefined,
calendars: Calendar[],
@@ -103,7 +186,7 @@ export interface ICalSubscription {
url: string;
calendarId: string;
// The JMAP account this subscription belongs to. Optional for back-
// compat with subs persisted before multi-account scoping landed
// compat with subs persisted before multi-account scoping landed -
// legacy entries with no accountId are shown only in whichever account
// the user has active (treated as floating). New subs always set it.
accountId?: string;
@@ -113,6 +196,17 @@ export interface ICalSubscription {
lastRefreshed: string | null;
}
/**
* One connected JMAP account. When the Pro shell aggregates calendars from
* every logged-in account, the page hands the calendar store a list of
* these so we can fetch + tag each account's data with its local app-store
* accountId (used to route mutations back to the right client).
*/
export interface CalendarAccountClient {
localAccountId: string;
client: IJMAPClient;
}
interface CalendarStore {
calendars: Calendar[];
events: CalendarEvent[];
@@ -129,6 +223,8 @@ interface CalendarStore {
setSupported: (supported: boolean) => void;
fetchCalendars: (client: IJMAPClient) => Promise<void>;
fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise<void>;
fetchAllAccountsCalendars: (accounts: CalendarAccountClient[], activeLocalAccountId: string) => Promise<void>;
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], activeLocalAccountId: string, start: string, end: string) => Promise<void>;
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
@@ -230,25 +326,92 @@ export const useCalendarStore = create<CalendarStore>()(
}
},
fetchAllAccountsCalendars: async (accounts, activeLocalAccountId) => {
set({ isLoading: true, error: null });
try {
const results = await Promise.all(
accounts.map(async ({ client, localAccountId }) => {
try {
const list = await client.getAllCalendars();
return prefixCalendarsWithLocalAccount(
list,
localAccountId,
localAccountId === activeLocalAccountId,
);
} catch (error) {
debug.error(`Failed to fetch calendars for account ${localAccountId}:`, error);
return [] as Calendar[];
}
}),
);
const calendars = results.flat();
const { selectedCalendarIds } = get();
const validIds = calendars.map(c => c.id);
const stillValid = selectedCalendarIds.filter(id => validIds.includes(id) || id === BIRTHDAY_CALENDAR_ID);
set({
calendars,
isLoading: false,
selectedCalendarIds: stillValid.length > 0 ? stillValid : validIds,
});
} catch (error) {
debug.error('Failed to fetch all-account calendars:', error);
set({ error: 'Failed to load calendars', isLoading: false });
}
},
fetchAllAccountsEvents: async (accounts, activeLocalAccountId, start, end) => {
set({ isLoadingEvents: true, error: null });
try {
const results = await Promise.all(
accounts.map(async ({ client, localAccountId }) => {
try {
const raw = await client.queryAllCalendarEvents({ after: start, before: end });
const valid = raw.filter(e => typeof e.start === 'string' && e.start);
const expanded = expandRecurringEvents(valid, start, end);
return prefixEventsWithLocalAccount(
expanded,
localAccountId,
localAccountId === activeLocalAccountId,
);
} catch (error) {
debug.error(`Failed to fetch events for account ${localAccountId}:`, error);
return [] as CalendarEvent[];
}
}),
);
set({ events: results.flat(), isLoadingEvents: false, dateRange: { start, end } });
} catch (error) {
debug.error('Failed to fetch all-account events:', error);
set({ error: 'Failed to load events', isLoadingEvents: false });
}
},
createEvent: async (client, event, sendSchedulingMessages) => {
set({ error: null });
try {
// Resolve shared calendar context from calendarIds
// Resolve shared calendar context from calendarIds. Also pin the
// local account from the calendar so we route through that
// server's client when in multi-account Pro mode.
let targetAccountId = event.accountId;
let localAccountId = event.localAccountId;
const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event });
if (event.calendarIds) {
const remapped: Record<string, boolean> = {};
for (const calId of Object.keys(event.calendarIds)) {
const cal = get().calendars.find(c => c.id === calId);
if (cal?.localAccountId) localAccountId = cal.localAccountId;
if (cal?.isShared && cal.originalId) {
targetAccountId = cal.accountId;
remapped[cal.originalId] = true;
} else if (cal?.originalId) {
remapped[cal.originalId] = true;
} else {
remapped[calId] = true;
}
}
cleanEvent.calendarIds = remapped;
}
client = resolveAccountClient(client, localAccountId);
if (event.originalCalendarIds) {
cleanEvent.calendarIds = event.originalCalendarIds;
}
@@ -321,8 +484,9 @@ export const useCalendarStore = create<CalendarStore>()(
try {
// Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === id);
const realId = storeEvent?.originalId || id;
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
const targetAccountId = storeEvent?.accountId;
client = resolveAccountClient(client, storeEvent?.localAccountId);
debug.log('calendar', 'Calendar updateEvent', {
storeId: id,
realId,
@@ -404,8 +568,9 @@ export const useCalendarStore = create<CalendarStore>()(
try {
// Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === eventId);
const realId = storeEvent?.originalId || eventId;
const realId = storeEvent?.originalId || stripLocalAccountPrefix(eventId, storeEvent?.localAccountId);
const targetAccountId = storeEvent?.accountId;
client = resolveAccountClient(client, storeEvent?.localAccountId);
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patchKey = `participants/${escapedId}/participationStatus`;
@@ -443,8 +608,9 @@ export const useCalendarStore = create<CalendarStore>()(
importEvents: async (client, events, calendarId) => {
// Resolve shared calendar IDs
const cal = get().calendars.find(c => c.id === calendarId);
const realCalendarId = cal?.originalId || calendarId;
const realCalendarId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
const targetAccountId = cal?.accountId;
client = resolveAccountClient(client, cal?.localAccountId);
// Deduplicate UIDs: Stalwart enforces UID uniqueness across all calendars.
// - Events already in the target calendar → skip (true duplicates)
@@ -611,8 +777,9 @@ export const useCalendarStore = create<CalendarStore>()(
try {
// Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === id);
const realId = storeEvent?.originalId || id;
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
const targetAccountId = storeEvent?.accountId;
client = resolveAccountClient(client, storeEvent?.localAccountId);
if (sendSchedulingMessages) {
try {
const event = await client.getCalendarEvent(realId, targetAccountId);
@@ -649,8 +816,9 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: null });
try {
const cal = get().calendars.find(c => c.id === calendarId);
const realId = cal?.originalId || calendarId;
const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
const targetAccountId = cal?.accountId;
client = resolveAccountClient(client, cal?.localAccountId);
await client.updateCalendar(realId, updates, targetAccountId);
set((state) => ({
calendars: state.calendars.map(c =>
@@ -668,8 +836,9 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: null });
try {
const cal = get().calendars.find(c => c.id === calendarId);
const realId = cal?.originalId || calendarId;
const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
const targetAccountId = cal?.accountId;
client = resolveAccountClient(client, cal?.localAccountId);
await client.setCalendarShare(realId, principalId, rights, targetAccountId);
set((state) => ({
calendars: state.calendars.map(c => {
@@ -707,8 +876,9 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: null });
try {
const cal = get().calendars.find(c => c.id === calendarId);
const realId = cal?.originalId || calendarId;
const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
const targetAccountId = cal?.accountId;
client = resolveAccountClient(client, cal?.localAccountId);
await client.deleteCalendar(realId, targetAccountId);
set((state) => ({
calendars: state.calendars.filter(c => c.id !== calendarId),
@@ -726,8 +896,9 @@ export const useCalendarStore = create<CalendarStore>()(
set({ error: null });
try {
const cal = get().calendars.find(c => c.id === calendarId);
const realCalId = cal?.originalId || calendarId;
const realCalId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
const targetAccountId = cal?.accountId;
client = resolveAccountClient(client, cal?.localAccountId);
let totalRemoved = 0;
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
let hasMore = true;
@@ -739,7 +910,7 @@ export const useCalendarStore = create<CalendarStore>()(
if (calendarEvents.length === 0) break;
// Separate events that live ONLY in this calendar (delete) from
// events also linked to other calendars (unlink only don't
// events also linked to other calendars (unlink only - don't
// cascade-delete the user's copy elsewhere).
const idsToDelete: string[] = [];
const eventsToUnlink: Array<{ id: string; calendarIds: Record<string, boolean> }> = [];
@@ -837,7 +1008,7 @@ export const useCalendarStore = create<CalendarStore>()(
icalSubscriptions: [...state.icalSubscriptions, subscription],
}));
// Initial fetch roll back the calendar create if it fails so we
// Initial fetch - roll back the calendar create if it fails so we
// don't leave a phantom calendar around after a bad URL / 404 / etc.
await get().refreshICalSubscription(client, subscription.id);
@@ -926,7 +1097,7 @@ export const useCalendarStore = create<CalendarStore>()(
if (!sub) return;
// Skip if the subscription is scoped to a different JMAP account
// than the one this client is talking to otherwise we'd create
// than the one this client is talking to - otherwise we'd create
// events in the wrong account / against a missing calendar.
if (sub.accountId && sub.accountId !== client.getAccountId()) {
debug.warn('calendar', 'Skipping subscription refresh: account mismatch', { sub: sub.name });
@@ -1057,7 +1228,7 @@ export const useCalendarStore = create<CalendarStore>()(
clearState: () => {
// Preserve iCal subscriptions across the account-switch teardown.
// They're now scoped per-account via sub.accountId wiping them
// They're now scoped per-account via sub.accountId - wiping them
// here would lose them from localStorage on every switch.
const preservedSubs = get().icalSubscriptions;
set({
+23
View File
@@ -0,0 +1,23 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
/**
* Tiny indirection used by the calendar and contact stores to look up a
* JMAP client by local account ID without importing `auth-store` directly
* - that would form a top-level cycle (auth-store already imports the
* feature stores to bootstrap them after login).
*
* `auth-store` registers its `getClientForAccount` on module init via
* `setClientLookup`; the feature stores call `getClientByLocalAccountId`
* inside their mutations.
*/
type ClientLookup = (localAccountId: string) => IJMAPClient | undefined;
let lookup: ClientLookup | null = null;
export function setClientLookup(fn: ClientLookup): void {
lookup = fn;
}
export function getClientByLocalAccountId(localAccountId: string): IJMAPClient | undefined {
return lookup ? lookup(localAccountId) : undefined;
}
+163 -7
View File
@@ -4,6 +4,82 @@ import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { generateUUID } from '@/lib/utils';
import { debug } from '@/lib/debug';
import { getClientByLocalAccountId } from './client-registry';
/** One connected JMAP account for contact multi-account aggregation. */
export interface ContactAccountClient {
localAccountId: string;
client: IJMAPClient;
}
/**
* Prefix used to namespace contact/address-book IDs that belong to a
* non-active JMAP account when the Pro shell aggregates across accounts.
* The active account's IDs are left untouched so existing single-account
* code paths keep working unchanged.
*/
const CROSS_ACCOUNT_ID_DELIMITER = '::';
function buildCrossAccountIdPrefix(localAccountId: string): string {
return `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`;
}
function prefixAddressBooksWithLocalAccount(
books: AddressBook[],
localAccountId: string,
isActiveAccount: boolean,
): AddressBook[] {
if (isActiveAccount) {
return books.map((b) => ({ ...b, localAccountId }));
}
const prefix = buildCrossAccountIdPrefix(localAccountId);
return books.map((b) => ({
...b,
id: `${prefix}${b.id}`,
localAccountId,
}));
}
function prefixContactsWithLocalAccount(
contacts: ContactCard[],
localAccountId: string,
isActiveAccount: boolean,
): ContactCard[] {
if (isActiveAccount) {
return contacts.map((c) => ({ ...c, localAccountId }));
}
const prefix = buildCrossAccountIdPrefix(localAccountId);
return contacts.map((c) => ({
...c,
id: `${prefix}${c.id}`,
localAccountId,
addressBookIds: c.addressBookIds
? Object.fromEntries(
Object.entries(c.addressBookIds).map(([bookId, v]) => [`${prefix}${bookId}`, v]),
)
: c.addressBookIds,
}));
}
/**
* Route mutations back through the client that owns the target entity
* when in multi-account Pro mode. See [[useProMultiAccountContacts]].
*
* Lookup goes through `client-registry` (not a direct auth-store import)
* to avoid a top-level cycle: auth-store already imports this module to
* bootstrap feature stores after login.
*/
function resolveAccountClient<T extends IJMAPClient>(active: T, localAccountId?: string): T {
if (!localAccountId) return active;
const lookup = getClientByLocalAccountId(localAccountId) as T | undefined;
return lookup ?? active;
}
function stripLocalAccountPrefix(id: string, localAccountId?: string): string {
if (!localAccountId) return id;
const prefix = `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`;
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
}
export function getContactDisplayName(contact: ContactCard): string {
if (contact.name) {
@@ -37,10 +113,27 @@ export function getContactPrimaryEmail(contact: ContactCard): string {
return Object.values(contact.emails)[0]?.address || '';
}
// Some JMAP servers (notably Stalwart, see issue #307) emit photo data URIs
// without a mediatype, like `data:base64,...` or `data:;base64,...`. Per
// RFC 2397 the missing/empty mediatype defaults to `text/plain`, so browsers
// won't render the bytes as an image. Rewrite to include a mediatype.
export function normalizeContactPhotoUri(uri: string, mediaType?: string): string {
const mime = mediaType && mediaType.includes('/') ? mediaType : 'image/jpeg';
if (uri.startsWith('data:base64,')) {
return `data:${mime};base64,${uri.slice('data:base64,'.length)}`;
}
if (uri.startsWith('data:;base64,')) {
return `data:${mime};base64,${uri.slice('data:;base64,'.length)}`;
}
return uri;
}
export function getContactPhotoUri(contact: ContactCard): string | undefined {
if (!contact.media) return undefined;
for (const media of Object.values(contact.media)) {
if (media.kind === 'photo' && media.uri) return media.uri;
if (media.kind === 'photo' && media.uri) {
return normalizeContactPhotoUri(media.uri, media.mediaType);
}
}
return undefined;
}
@@ -68,6 +161,8 @@ interface ContactStore {
fetchContacts: (client: IJMAPClient) => Promise<void>;
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
fetchAllAccountsContacts: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
createContact: (client: IJMAPClient, contact: Partial<ContactCard>) => Promise<void>;
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
deleteContact: (client: IJMAPClient, id: string) => Promise<void>;
@@ -185,12 +280,64 @@ export const useContactStore = create<ContactStore>()(
}
},
fetchAllAccountsContacts: async (accounts, activeLocalAccountId) => {
set({ isLoading: true, error: null });
try {
const results = await Promise.all(
accounts.map(async ({ client, localAccountId }) => {
try {
const list = await client.getAllContacts();
return prefixContactsWithLocalAccount(
list,
localAccountId,
localAccountId === activeLocalAccountId,
);
} catch (error) {
debug.error(`Failed to fetch contacts for account ${localAccountId}:`, error);
return [] as ContactCard[];
}
}),
);
set({ contacts: results.flat(), isLoading: false });
} catch (error) {
console.error('Failed to fetch all-account contacts:', error);
set({ error: 'Failed to fetch contacts', isLoading: false });
}
},
fetchAllAccountsAddressBooks: async (accounts, activeLocalAccountId) => {
try {
const results = await Promise.all(
accounts.map(async ({ client, localAccountId }) => {
try {
const list = await client.getAllAddressBooks();
return prefixAddressBooksWithLocalAccount(
list,
localAccountId,
localAccountId === activeLocalAccountId,
);
} catch (error) {
debug.error(`Failed to fetch address books for account ${localAccountId}:`, error);
return [] as AddressBook[];
}
}),
);
set({ addressBooks: results.flat() });
} catch (error) {
console.error('Failed to fetch all-account address books:', error);
set({ error: 'Failed to fetch address books' });
}
},
createContact: async (client, contact) => {
set({ isLoading: true, error: null });
try {
// Determine target account from the selected address book
// Determine target account from the selected address book. Also
// pin the local account so we route through the right server's
// client in multi-account Pro mode.
let accountId = contact.isShared ? contact.accountId : undefined;
let cleanedContact = contact;
let localAccountId = contact.localAccountId;
// De-namespace addressBookIds if they reference a shared address book
if (contact.addressBookIds) {
@@ -199,9 +346,12 @@ export const useContactStore = create<ContactStore>()(
let sharedAccountId: string | undefined;
for (const [bookId, value] of Object.entries(contact.addressBookIds)) {
const book = books.find(b => b.id === bookId);
if (book?.localAccountId) localAccountId = book.localAccountId;
if (book?.isShared && book.originalId) {
deNamespaced[book.originalId] = value;
sharedAccountId = book.accountId;
} else if (book?.originalId) {
deNamespaced[book.originalId] = value;
} else {
deNamespaced[bookId] = value;
}
@@ -214,6 +364,7 @@ export const useContactStore = create<ContactStore>()(
}
}
client = resolveAccountClient(client, localAccountId);
const created = await client.createContact(cleanedContact, accountId);
// Preserve shared account metadata
if (contact.isShared && contact.accountId) {
@@ -238,8 +389,9 @@ export const useContactStore = create<ContactStore>()(
set({ error: null });
try {
const contact = get().contacts.find(c => c.id === id);
const originalId = contact?.originalId || id;
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
const accountId = contact?.isShared ? contact.accountId : undefined;
client = resolveAccountClient(client, contact?.localAccountId);
// De-namespace addressBookIds for shared contacts before sending to JMAP server
let cleanedUpdates = updates;
@@ -271,8 +423,9 @@ export const useContactStore = create<ContactStore>()(
set({ error: null });
try {
const contact = get().contacts.find(c => c.id === id);
const originalId = contact?.originalId || id;
const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId);
const accountId = contact?.isShared ? contact.accountId : undefined;
client = resolveAccountClient(client, contact?.localAccountId);
await client.deleteContact(originalId, accountId);
set((state) => {
const removedIds = new Set([id]);
@@ -642,8 +795,9 @@ export const useContactStore = create<ContactStore>()(
const trimmed = newName.trim();
if (!trimmed) return;
try {
const originalId = addressBook.originalId || addressBook.id;
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
client = resolveAccountClient(client, addressBook.localAccountId);
await client.updateAddressBook(originalId, { name: trimmed }, accountId);
set((state) => ({
addressBooks: state.addressBooks.map(b =>
@@ -660,8 +814,9 @@ export const useContactStore = create<ContactStore>()(
removeAddressBook: async (client, addressBook) => {
set({ error: null });
try {
const originalId = addressBook.originalId || addressBook.id;
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
client = resolveAccountClient(client, addressBook.localAccountId);
await client.deleteAddressBook(originalId, accountId);
set((state) => ({
addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id),
@@ -677,8 +832,9 @@ export const useContactStore = create<ContactStore>()(
shareAddressBook: async (client, addressBook, principalId, rights) => {
set({ error: null });
try {
const originalId = addressBook.originalId || addressBook.id;
const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId);
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
client = resolveAccountClient(client, addressBook.localAccountId);
await client.setAddressBookShare(originalId, principalId, rights, accountId);
set((state) => ({
addressBooks: state.addressBooks.map(b => {
+459 -104
View File
@@ -7,7 +7,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
@@ -25,6 +25,21 @@ type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string;
interface EmailStore {
emails: Email[];
mailboxes: Mailbox[];
/**
* Mailbox caches keyed by accountId. Populated for every connected account
* when the Pro shell is active so the sidebar can render per-account groups
* Thunderbird-style. The active account's mailboxes still live in
* `mailboxes` for back-compat with the single-account view.
*/
accountMailboxes: Record<string, Mailbox[]>;
/**
* When set, the mail view is reading from this account instead of the
* global active one. `null` means "use the global active account" - i.e.
* the standard single-account behavior. Selecting a folder under a
* non-active account in the Pro sidebar updates this without changing
* `useAuthStore.activeAccountId`.
*/
viewingAccountId: string | null;
selectedEmail: Email | null;
selectedMailbox: string;
isLoading: boolean;
@@ -75,6 +90,23 @@ interface EmailStore {
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
/** Cache or update the mailbox list for a specific account. */
setAccountMailboxes: (accountId: string, mailboxes: Mailbox[]) => void;
/** Wipe the per-account mailbox cache (e.g. on logout). */
clearAccountMailboxes: () => void;
setViewingAccount: (accountId: string | null) => void;
/**
* Atomic version of (setViewingAccount + selectMailbox). Pass `null` for
* the active account; pass an accountId to view a non-active account's
* folder without changing the global active account.
*/
selectAccountMailbox: (accountId: string | null, mailboxId: string) => void;
/**
* Fetch mailboxes via the supplied client and store them under
* `accountMailboxes[accountId]`. Used by the Pro shell to populate the
* sidebar's per-account groups for every connected account.
*/
fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise<void>;
selectEmail: (email: Email | null) => void;
selectMailbox: (mailboxId: string) => void;
setLoading: (loading: boolean) => void;
@@ -107,6 +139,20 @@ interface EmailStore {
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise<void>;
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
/**
* Move emails across JMAP accounts. JMAP has no native cross-account move,
* so for each email we fetch the source's raw RFC822 blob, import it into
* the destination account's target mailbox, then delete the original.
* `emailIdsBySource` maps each source accountId to the emails it owns;
* pass the active account's id explicitly (no `__default__` sentinel).
* `destMailboxId` is the raw JMAP id on the destination server (not the
* `accountId:mailboxId` namespace used for shared folders).
*/
crossAccountMoveEmails: (
emailIdsBySource: Map<string, string[]>,
destAccountId: string,
destMailboxId: string,
) => Promise<void>;
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
advancedSearch: (client: IJMAPClient) => Promise<void>;
setSearchFilters: (filters: Partial<SearchFilters>) => void;
@@ -231,6 +277,78 @@ function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEm
return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending';
}
/**
* When the mail view is showing a non-active account (Pro shell's
* Thunderbird-style sidebar), redirect read/write operations to that
* account's JMAP client and mailbox cache. Returns the passed-in values
* unchanged for the standard single-account flow.
*
* Compose/send still routes through the caller's client (the active
* account), since identity binding for cross-account sending is a separate
* concern.
*/
function resolveActionClient(passedClient: IJMAPClient): IJMAPClient {
const viewingId = useEmailStore.getState().viewingAccountId;
if (!viewingId) return passedClient;
const c = useAuthStore.getState().getClientForAccount(viewingId);
return c ?? passedClient;
}
function resolveActionMailboxes(): Mailbox[] {
const state = useEmailStore.getState();
if (state.viewingAccountId) {
return state.accountMailboxes[state.viewingAccountId] ?? state.mailboxes;
}
return state.mailboxes;
}
/**
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
* action (browse, load-more, search). Each entry has a JMAP client plus a
* fresh mailbox list so the helpers can resolve the role mailbox per account.
* Accounts whose mailbox fetch fails are skipped - the unified result will
* surface that in its per-account error map.
*/
async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const allClients = useAuthStore.getState().getAllConnectedClients();
const built: UnifiedAccountClient[] = [];
for (const a of authAccounts) {
const c = allClients.get(a.id);
if (!c) continue;
try {
const mailboxes = await c.getMailboxes();
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes });
} catch {
/* skip account on mailbox fetch failure */
}
}
return built;
}
/**
* After a mailbox-list mutation (create/rename/delete/etc.), refresh the
* cache for whichever account we're operating on. Writes the result to the
* standard `mailboxes` slot for the active account, or the per-account
* cache for non-active accounts so the Pro sidebar stays in sync.
*/
async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> {
const viewingId = useEmailStore.getState().viewingAccountId;
const client = resolveActionClient(fallbackClient);
try {
const mailboxes = await client.getMailboxes();
if (viewingId) {
useEmailStore.setState((state) => ({
accountMailboxes: { ...state.accountMailboxes, [viewingId]: mailboxes },
}));
} else {
useEmailStore.setState({ mailboxes });
}
} catch (error) {
console.error('Failed to refresh mailboxes after mutation:', error);
}
}
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
// falls back to name matching ("trash" / "deleted") so users with custom or
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
@@ -256,6 +374,8 @@ function findTrashMailbox(
export const useEmailStore = create<EmailStore>((set, get) => ({
emails: [],
mailboxes: [],
accountMailboxes: {},
viewingAccountId: null,
selectedEmail: null,
selectedMailbox: "",
isLoading: false,
@@ -309,6 +429,33 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
setAccountMailboxes: (accountId, mailboxes) => set((state) => ({
accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes },
})),
clearAccountMailboxes: () => set({ accountMailboxes: {} }),
setViewingAccount: (accountId) => set({ viewingAccountId: accountId }),
selectAccountMailbox: (accountId, mailboxId) => set({
viewingAccountId: accountId,
selectedMailbox: mailboxId,
selectedEmail: null,
selectedEmailIds: new Set(),
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
}),
fetchAccountMailboxes: async (client, accountId) => {
try {
const mailboxes = await client.getMailboxes();
// Re-check the cache after the await to avoid stomping a more recent
// fetch that finished while this one was in flight.
set((state) => ({
accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes },
}));
} catch (error) {
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
}
},
selectEmail: (email) => {
const prev = get().selectedEmail;
set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId });
@@ -334,7 +481,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return;
}
const tagIds = keywords.map(k => k.id);
const counts = await client.getTagCounts(tagIds);
const counts = await resolveActionClient(client).getTagCounts(tagIds);
set({ tagCounts: counts });
} catch (error) {
console.error('Failed to fetch tag counts:', error);
@@ -464,9 +611,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await get().fetchScheduledEmails(client);
return;
}
const effectiveClient = resolveActionClient(client);
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === targetMailboxId);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
@@ -482,7 +630,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// When filtering by tag, omit the mailbox constraint so emails across
// all folders that carry the tag are returned.
const result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
@@ -507,27 +655,28 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return;
// Unified view uses a different fan-out loader. Rebuild the per-account
// client list from auth/account stores and delegate.
// Unified view uses a different fan-out loader. When a search query or
// advanced filter is active we paginate the unified search instead of the
// unified browse, so "load more" matches what's on screen.
if (isUnifiedView && unifiedRole) {
set({ isLoadingMore: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const position = emails.length;
const authAccounts = useAccountStore.getState().accounts.filter(a => a.isConnected);
const allClients = useAuthStore.getState().getAllConnectedClients();
const built: UnifiedAccountClient[] = [];
for (const a of authAccounts) {
const c = allClients.get(a.id);
if (!c) continue;
try {
const mailboxes = await c.getMailboxes();
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes });
} catch {
/* skip account on mailbox fetch failure */
}
}
const result = await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position);
const built = await buildUnifiedAccountClients();
const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
const result = hasFilters
? await advancedSearchUnifiedEmails(
built,
unifiedRole,
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
emailsPerPage,
position,
)
: searchQuery
? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, position)
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position);
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
@@ -556,6 +705,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return;
}
const effectiveClient = resolveActionClient(client);
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
@@ -568,21 +718,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const hasFilters = !isFilterEmpty(searchFilters);
if (searchQuery || hasFilters) {
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
if (hasFilters) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position);
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position);
} else {
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
result = await effectiveClient.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
}
} else {
// Load more from mailbox
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
@@ -590,7 +740,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails).
result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
}
// Use fresh state when merging to avoid overwriting concurrent updates
@@ -621,13 +771,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
// Find the selected mailbox to determine accountId (for shared folders)
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const email = await client.getEmail(emailId, accountId);
const email = await resolveActionClient(client).getEmail(emailId, accountId);
if (email) {
const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId);
@@ -645,7 +795,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
fetchQuota: async (client) => {
try {
const quota = await client.getQuota();
const quota = await resolveActionClient(client).getQuota();
set({ quota });
} catch {
// Don't set error state as quota is optional
@@ -704,6 +854,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (!email) return;
const isUnread = !email.keywords?.$seen;
const effectiveClient = resolveActionClient(client);
// Get delete action preference from settings
const deleteAction = useSettingsStore.getState().deleteAction;
@@ -711,7 +862,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
@@ -728,7 +879,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (trashMailbox) {
// Use originalId for shared mailboxes if available
const trashId = trashMailbox.originalId || trashMailbox.id;
await client.moveToTrash(emailId, trashId, accountId);
await effectiveClient.moveToTrash(emailId, trashId, accountId);
// Remove from local state (email moved to trash, not in current view)
set((state) => {
@@ -775,7 +926,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
// Permanent delete
await client.deleteEmail(emailId);
await effectiveClient.deleteEmail(emailId);
// Remove from local state and update mailbox counters if needed
set((state) => {
@@ -849,11 +1000,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
await client.markAsRead(emailId, read, accountId);
await resolveActionClient(client).markAsRead(emailId, read, accountId);
// Update local state including mailbox counters
set((state) => {
@@ -917,14 +1068,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const isUnread = !email.keywords?.$seen;
const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
const { selectedMailbox, mailboxes } = get();
const { selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
await client.moveEmail(emailId, jmapDestId, accountId);
await resolveActionClient(client).moveEmail(emailId, jmapDestId, accountId);
set((state) => {
const updatedMailboxes = state.mailboxes.map(mailbox => {
@@ -971,7 +1123,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
try {
const { emails, mailboxes, selectedMailbox, isUnifiedView } = get();
const { emails, selectedMailbox, isUnifiedView } = get();
const mailboxes = resolveActionMailboxes();
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
const idSet = new Set(emailIds);
@@ -993,7 +1146,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} else {
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
await client.batchMoveEmails(emailIds, jmapDestId, accountId);
await resolveActionClient(client).batchMoveEmails(emailIds, jmapDestId, accountId);
}
// Adjust counters and drop moved emails from the current view.
@@ -1037,6 +1190,110 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => {
if (emailIdsBySource.size === 0) return;
set({ isLoading: true, error: null });
try {
const destClient = useAuthStore.getState().getClientForAccount(destAccountId);
if (!destClient) {
throw new Error('Destination account is not connected');
}
const movedIds: string[] = [];
const failures: Array<{ emailId: string; error: string }> = [];
for (const [sourceAccountId, emailIds] of emailIdsBySource.entries()) {
const sourceClient = useAuthStore.getState().getClientForAccount(sourceAccountId);
if (!sourceClient) {
for (const emailId of emailIds) {
failures.push({ emailId, error: 'Source account not connected' });
}
continue;
}
// Fan the per-email copy/import/delete pipeline out in parallel.
// JMAP has no atomic cross-account move, so we accept that a crash
// mid-flight could leave a duplicate; the delete on success keeps
// the source clean in the happy path.
const results = await Promise.allSettled(
emailIds.map(async (emailId) => {
const full = await sourceClient.getEmail(emailId);
if (!full?.blobId) {
throw new Error('Source email has no raw blob to copy');
}
const blob = await sourceClient.fetchBlob(full.blobId);
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) };
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords);
await sourceClient.deleteEmail(emailId);
return emailId;
}),
);
results.forEach((outcome, i) => {
const emailId = emailIds[i];
if (outcome.status === 'fulfilled') {
movedIds.push(emailId);
} else {
const err = outcome.reason;
failures.push({
emailId,
error: err instanceof Error ? err.message : String(err),
});
}
});
}
// Drop the moved emails from the current view and clear stale selection
// entries. Counter accuracy comes from the mailbox refresh below.
const movedSet = new Set(movedIds);
set((state) => ({
emails: state.emails.filter((e) => !movedSet.has(e.id)),
selectedEmail:
state.selectedEmail && movedSet.has(state.selectedEmail.id)
? null
: state.selectedEmail,
selectedEmailIds: (() => {
const next = new Set(state.selectedEmailIds);
for (const id of movedIds) next.delete(id);
return next;
})(),
isLoading: false,
}));
// Refresh mailbox folder lists/counters for every account we touched.
// Background-only so the move feels instant - counters will catch up.
const activeAccountId = useAuthStore.getState().activeAccountId;
const touched = new Set<string>([destAccountId, ...emailIdsBySource.keys()]);
for (const acctId of touched) {
const c = useAuthStore.getState().getClientForAccount(acctId);
if (!c) continue;
if (acctId === activeAccountId) {
void get().fetchMailboxes(c);
} else {
void get().fetchAccountMailboxes(c, acctId);
}
}
if (failures.length > 0) {
const first = failures[0];
throw new Error(
failures.length === 1
? `Failed to move email: ${first.error}`
: `Failed to move ${failures.length} email(s); first error: ${first.error}`,
);
}
} catch (error) {
set({
isLoading: false,
error:
error instanceof Error
? error.message
: 'Failed to move emails between accounts',
});
throw error;
}
},
moveThreadToMailbox: async (client, emailId, destinationMailboxId) => {
try {
const state = get();
@@ -1048,12 +1305,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return;
}
const currentMailbox = state.mailboxes.find(mb => mb.id === state.selectedMailbox);
const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client);
const currentMailbox = mailboxes.find(mb => mb.id === state.selectedMailbox);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
const destMailbox = state.mailboxes.find(mb => mb.id === destinationMailboxId);
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
const thread = await client.getThread(email.threadId, accountId);
const thread = await effectiveClient.getThread(email.threadId, accountId);
const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId];
if (threadEmailIds.length <= 1) {
@@ -1061,7 +1320,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return;
}
await client.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
const removedEmailIds = new Set(threadEmailIds);
set((currentState) => {
@@ -1093,18 +1352,34 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try {
const { isUnifiedView, unifiedRole } = get();
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) {
const built = await buildUnifiedAccountClients();
const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
}
// Get the current mailbox to scope the search
const selectedMailbox = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Use originalId for shared mailboxes
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
@@ -1126,7 +1401,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
advancedSearch: async (client) => {
const { searchQuery, searchFilters, selectedMailbox, mailboxes, searchAbortController } = get();
const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole } = get();
const mailboxes = resolveActionMailboxes();
if (searchAbortController) {
searchAbortController.abort();
@@ -1143,13 +1419,37 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) {
const built = await buildUnifiedAccountClients();
const result = await advancedSearchUnifiedEmails(
built,
unifiedRole,
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
emailsPerPage,
0,
);
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
searchAbortController: null,
unifiedErrors: result.errors,
});
return;
}
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
if (controller.signal.aborted) return;
@@ -1197,7 +1497,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (!email) return;
const isFlagged = email.keywords.$flagged || false;
await client.toggleStar(emailId, !isFlagged);
await resolveActionClient(client).toggleStar(emailId, !isFlagged);
// Update local state
set((state) => ({
@@ -1229,7 +1529,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Batch operations
batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails, mailboxes } = get();
const { selectedEmailIds, emails } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
@@ -1253,7 +1554,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
await Promise.allSettled(promises);
} else {
await client.batchMarkAsRead(emailIdsArray, read);
await resolveActionClient(client).batchMarkAsRead(emailIdsArray, read);
}
// Update local state
@@ -1298,7 +1599,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchDelete: async (client, permanent = false) => {
const { selectedEmailIds, emails, mailboxes, selectedMailbox } = get();
const { selectedEmailIds, emails, selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
@@ -1461,7 +1763,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
await Promise.allSettled(promises);
} else {
await client.batchMoveEmails(emailIdsArray, toMailboxId);
await resolveActionClient(client).batchMoveEmails(emailIdsArray, toMailboxId);
}
// Update local state - remove from current view since they moved
@@ -1486,7 +1788,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchArchive: async (client) => {
const { selectedEmailIds, emails, mailboxes, fetchMailboxes } = get();
const { selectedEmailIds, emails } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
@@ -1500,7 +1803,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null });
try {
await client.batchArchiveEmails(
await resolveActionClient(client).batchArchiveEmails(
selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })),
archiveId,
mode,
@@ -1511,8 +1814,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const remaining = emails.filter(e => !selectedEmailIds.has(e.id));
set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false });
await fetchMailboxes(client);
// Refresh the current mailbox view (honors active search/filters)
// Refresh the active or viewed account's mailbox cache after the
// archive (a year/month archive can create new sub-folders).
await refreshMailboxesForViewingAccount(client);
await get().refreshCurrentMailbox(client);
} catch (error) {
set({
@@ -1525,7 +1829,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Spam operations
markAsSpam: async (client, emailId) => {
const { selectedMailbox, mailboxes, emails } = get();
const { selectedMailbox, emails } = get();
const mailboxes = resolveActionMailboxes();
const email = emails.find(e => e.id === emailId);
if (!email) return;
@@ -1539,7 +1844,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
try {
await client.markAsSpam(emailId, currentMailbox.accountId);
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId);
set(state => ({
emails: state.emails.filter(e => e.id !== emailId),
@@ -1552,7 +1857,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
undoSpam: async (client, emailId) => {
const { mailboxes, selectedMailbox } = get();
const { selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
// Try cache first (preserves exact original mailbox for toast undo)
const cachedData = get().spamUndoCache.get(emailId);
@@ -1584,7 +1890,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
try {
await client.undoSpam(emailId, targetMailboxId, accountId);
await resolveActionClient(client).undoSpam(emailId, targetMailboxId, accountId);
await get().fetchEmails(client, selectedMailbox);
} catch (error) {
console.error('Failed to restore email:', error);
@@ -1593,14 +1899,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchMarkAsSpam: async (client, emailIds) => {
const { selectedMailbox, mailboxes } = get();
const { selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client);
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
try {
for (const emailId of emailIds) {
await client.markAsSpam(emailId, currentMailbox.accountId);
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId);
}
set(state => ({
@@ -1615,7 +1923,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => {
const { mailboxes, selectedMailbox } = get();
const { selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client);
// Find inbox (batch operations don't preserve original mailboxes)
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
@@ -1632,7 +1942,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
for (const emailId of emailIds) {
await client.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
}
set(state => ({
@@ -1731,7 +2041,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
// Fetch emails for the current mailbox without clearing the list first
// This provides a smoother update experience
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client);
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
@@ -1747,9 +2058,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
let result;
if (hasFilters || searchQuery) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
} else {
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
}
const currentEmails = get().emails;
@@ -1844,7 +2155,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
fetchThreadEmails: async (client, threadId) => {
const { threadEmailsCache, selectedMailbox, mailboxes } = get();
const { threadEmailsCache, selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
// Check if we already have this thread cached
const cachedEmails = threadEmailsCache.get(threadId);
@@ -1861,7 +2173,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Fetch all emails in the thread
const emails = await client.getThreadEmails(threadId, accountId);
const emails = await resolveActionClient(client).getThreadEmails(threadId, accountId);
// Update cache
const newCache = new Map(get().threadEmailsCache);
@@ -1896,8 +2208,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Mailbox management
createMailbox: async (client, name, parentId) => {
try {
await client.createMailbox(name, parentId);
await get().fetchMailboxes(client);
await resolveActionClient(client).createMailbox(name, parentId);
if (get().viewingAccountId) {
await refreshMailboxesForViewingAccount(client);
} else {
await get().fetchMailboxes(client);
}
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to create folder' });
throw error;
@@ -1906,12 +2222,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
renameMailbox: async (client, mailboxId, name) => {
try {
await client.updateMailbox(mailboxId, { name });
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId ? { ...mb, name } : mb
),
});
await resolveActionClient(client).updateMailbox(mailboxId, { name });
const viewingId = get().viewingAccountId;
if (viewingId) {
set((state) => ({
accountMailboxes: {
...state.accountMailboxes,
[viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb =>
mb.id === mailboxId ? { ...mb, name } : mb
),
},
}));
} else {
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId ? { ...mb, name } : mb
),
});
}
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to rename folder' });
throw error;
@@ -1920,18 +2248,27 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
deleteMailbox: async (client, mailboxId) => {
try {
await client.deleteMailbox(mailboxId);
const { mailboxes, selectedMailbox } = get();
const newMailboxes = mailboxes.filter(mb => mb.id !== mailboxId);
const updates: Partial<EmailStore> = { mailboxes: newMailboxes };
// If the deleted mailbox was selected, switch to inbox
if (selectedMailbox === mailboxId) {
const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared);
if (inbox) {
updates.selectedMailbox = inbox.id;
await resolveActionClient(client).deleteMailbox(mailboxId);
const { selectedMailbox, viewingAccountId: viewingId } = get();
if (viewingId) {
const updatedList = (get().accountMailboxes[viewingId] ?? []).filter(mb => mb.id !== mailboxId);
const patch: Partial<EmailStore> = {
accountMailboxes: { ...get().accountMailboxes, [viewingId]: updatedList },
};
if (selectedMailbox === mailboxId) {
const inbox = updatedList.find(mb => mb.role === 'inbox' && !mb.isShared);
if (inbox) patch.selectedMailbox = inbox.id;
}
set(patch);
} else {
const newMailboxes = get().mailboxes.filter(mb => mb.id !== mailboxId);
const updates: Partial<EmailStore> = { mailboxes: newMailboxes };
if (selectedMailbox === mailboxId) {
const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared);
if (inbox) updates.selectedMailbox = inbox.id;
}
set(updates);
}
set(updates as EmailStore);
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to delete folder' });
throw error;
@@ -1940,15 +2277,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
setMailboxRole: async (client, mailboxId, role) => {
try {
const effectiveClient = resolveActionClient(client);
// If assigning a role, first clear that role from ALL other mailboxes that have it
if (role) {
const existingMailboxes = get().mailboxes.filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId);
const existingMailboxes = resolveActionMailboxes().filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId);
for (const existing of existingMailboxes) {
await client.updateMailbox(existing.id, { role: null });
await effectiveClient.updateMailbox(existing.id, { role: null });
}
}
await client.updateMailbox(mailboxId, { role });
await get().fetchMailboxes(client);
await effectiveClient.updateMailbox(mailboxId, { role });
if (get().viewingAccountId) {
await refreshMailboxesForViewingAccount(client);
} else {
await get().fetchMailboxes(client);
}
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to update folder role' });
throw error;
@@ -1958,7 +2300,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
emptyMailbox: async (client, mailboxId) => {
try {
set({ isLoading: true, error: null });
await client.emptyMailbox(mailboxId);
await resolveActionClient(client).emptyMailbox(mailboxId);
// Clear emails from local state if we're viewing this mailbox
const currentMailbox = get().selectedMailbox;
@@ -1966,15 +2308,28 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ emails: [], selectedEmail: null });
}
// Update mailbox counters
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId
? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }
: mb
),
isLoading: false,
});
const viewingId = get().viewingAccountId;
if (viewingId) {
set((state) => ({
accountMailboxes: {
...state.accountMailboxes,
[viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb =>
mb.id === mailboxId
? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }
: mb
),
},
}));
} else {
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId
? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }
: mb
),
});
}
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to empty folder',
@@ -1986,11 +2341,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
markMailboxAsRead: async (client, mailboxId) => {
try {
const mailbox = get().mailboxes.find(mb => mb.id === mailboxId);
const mailbox = resolveActionMailboxes().find(mb => mb.id === mailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const jmapMailboxId = mailbox?.originalId || mailboxId;
const count = await client.markMailboxAsRead(jmapMailboxId, accountId);
const count = await resolveActionClient(client).markMailboxAsRead(jmapMailboxId, accountId);
// Update local state: mark all emails currently visible in this mailbox as read,
// and zero-out the mailbox unread counter.
+25 -3
View File
@@ -48,6 +48,8 @@ interface FileState {
selectedResources: Set<string>;
uploadProgress: UploadProgress | null;
client: IJMAPClient | null;
/** Which connected account's files are being browsed. Pro shell only - null in single-account contexts. */
currentAccountId: string | null;
clipboard: ClipboardState | null;
uploadAbortController: AbortController | null;
favorites: string[];
@@ -55,7 +57,9 @@ interface FileState {
lastAction: UndoAction | null;
// Actions
initClient: (client: IJMAPClient) => void;
initClient: (client: IJMAPClient, accountId?: string | null) => void;
/** Detach the current client and reset browse state. Used by the Pro shell to return to the cross-account picker. */
clearClient: () => void;
checkSupport: () => Promise<boolean>;
navigate: (parentId: string | null, name?: string) => Promise<void>;
navigateByPath: (path: string) => Promise<void>;
@@ -168,6 +172,7 @@ export const useFileStore = create<FileState>((set, get) => ({
selectedResources: new Set<string>(),
uploadProgress: null,
client: null,
currentAccountId: null,
clipboard: null,
uploadAbortController: null,
lastAction: null,
@@ -178,8 +183,25 @@ export const useFileStore = create<FileState>((set, get) => ({
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
})(),
initClient: (client: IJMAPClient) => {
set({ client });
initClient: (client: IJMAPClient, accountId?: string | null) => {
const patch: Partial<FileState> = { client };
if (accountId !== undefined) patch.currentAccountId = accountId;
set(patch);
},
clearClient: () => {
set({
client: null,
currentAccountId: null,
supportsFiles: null,
pathStack: [{ id: null, name: '' }],
currentPath: '/',
currentParentId: null,
resources: [],
selectedResources: new Set<string>(),
error: null,
isLoading: false,
});
},
checkSupport: async () => {
+1 -1
View File
@@ -9,7 +9,7 @@ interface LocaleStore {
export const useLocaleStore = create<LocaleStore>()(
persist(
(set) => ({
locale: 'en',
locale: '',
setLocale: (locale) => set({ locale }),
}),
{
+12 -8
View File
@@ -155,7 +155,7 @@ export const usePluginStore = create<PluginStoreState>()(
}));
return;
} else {
// 'pending' or 'not-requested' submit a request and refuse to enable.
// 'pending' or 'not-requested' - submit a request and refuse to enable.
await submitApprovalRequest(plugin).catch(() => { /* best effort */ });
set(state => ({
plugins: state.plugins.map(p =>
@@ -165,12 +165,12 @@ export const usePluginStore = create<PluginStoreState>()(
return;
}
} else if (requireApproval && !policyApproved) {
// No bundleHash means we can't pin the approval refuse.
// No bundleHash means we can't pin the approval - refuse.
return;
}
// Per-user consent gate: prompt for any permission the user has not
// explicitly approved yet. Managed plugins (admin-pushed) skip this
// explicitly approved yet. Managed plugins (admin-pushed) skip this -
// the admin has already approved them at install time.
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
const granted = new Set<string>(plugin.grantedPermissions ?? []);
@@ -262,11 +262,11 @@ export const usePluginStore = create<PluginStoreState>()(
// Sync server-managed plugins before loading
await syncServerPlugins(get, set);
// Load all enabled plugins
// Load all enabled plugins in parallel. Sequential `await` made one
// hung/slow plugin block every subsequent one; loadSandboxedPlugin
// catches its own errors so allSettled is just for tidy completion.
const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error');
for (const plugin of enabledPlugins) {
await loadPlugin(plugin);
}
await Promise.allSettled(enabledPlugins.map(plugin => loadPlugin(plugin)));
set({ initialized: true });
})();
@@ -474,6 +474,9 @@ async function syncServerPlugins(
),
}));
} else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) {
// When forceEnabled flips on, enable the plugin in the same pass so
// the user doesn't need a second refresh for it to run.
const shouldAutoEnable = sp.forceEnabled && !local.enabled;
set(state => ({
plugins: state.plugins.map(p =>
p.id === sp.id
@@ -482,6 +485,7 @@ async function syncServerPlugins(
managed: true,
forceEnabled: sp.forceEnabled,
settingsSchema: sp.settingsSchema,
...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}),
}
: p
),
@@ -545,7 +549,7 @@ async function downloadPluginBundle(pluginId: string, bundleHash?: string): Prom
// Ed25519 signature verification. Present on every server-managed bundle
// since the signing module is server-side; refuse to persist a bundle
// that fails verification. If the header is missing (older server / dev
// build with signing disabled) we log and allow the SHA-256 hash check
// build with signing disabled) we log and allow - the SHA-256 hash check
// at load time still catches transport corruption.
const sig = res.headers.get('X-Bundle-Signature');
if (sig) {
+4 -4
View File
@@ -8,7 +8,7 @@ export type ProTabKind =
export type ProPaneId = 'main' | 'split';
/**
* Pro split layout. Only side-by-side is supported the pane that "splits
* Pro split layout. Only side-by-side is supported - the pane that "splits
* off" always lives next to the main pane on the horizontal axis. Kept as
* a type alias to leave room for future layouts without churning callers.
*/
@@ -17,7 +17,7 @@ export type ProSplitOrientation = 'vertical';
export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward';
/**
* Mirror of `EmailComposer.replyTo` kept as a structural type here so the
* Mirror of `EmailComposer.replyTo` - kept as a structural type here so the
* tab store doesn't take a runtime dependency on the composer module.
*/
export interface ProReplyContext {
@@ -92,7 +92,7 @@ interface ProTabState {
/**
* Move a tab next to another tab. `edge` controls whether it lands before
* or after the target used by the tab bar's drop indicator. Reordering
* or after the target - used by the tab bar's drop indicator. Reordering
* works both within a pane and across panes (cross-pane drops move the
* tab to the target pane).
*/
@@ -476,7 +476,7 @@ export const useProTabStore = create<ProTabState>()(
{
name: 'pro-tabs',
version: 3,
// Don't persist transient compose drafts in tab metadata the composer's
// Don't persist transient compose drafts in tab metadata - the composer's
// own draft-store already handles that. Persisted email tabs are fine to
// restore (the tab body refetches the email by id).
partialize: (state) => ({
+14 -2
View File
@@ -44,6 +44,14 @@ export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s
export type SendDelaySeconds = 0 | 10 | 30 | 60;
export type ProtocolOpenMode = 'active-session' | 'new-tab';
/**
* Settings that must never round-trip through the cross-device sync API.
* Decided per device and kept only in the local zustand-persist storage -
* a value already stored on the server (from a prior build) is ignored on
* import.
*/
const DEVICE_LOCAL_SETTING_KEYS = new Set<string>(['proInterface']);
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
export type HoverActionsMode = 'inline' | 'floating';
export type HoverActionsCorner = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
@@ -516,7 +524,8 @@ export const useSettingsStore = create<SettingsState>()(
toolbarPosition: state.toolbarPosition,
hideAccountSwitcher: state.hideAccountSwitcher,
showRailAccountList: state.showRailAccountList,
proInterface: state.proInterface,
// proInterface is intentionally omitted - it's a per-device choice
// (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced.
enableUnifiedMailbox: state.enableUnifiedMailbox,
senderFavicons: state.senderFavicons,
showAvatarsInJunk: state.showAvatarsInJunk,
@@ -565,6 +574,9 @@ export const useSettingsStore = create<SettingsState>()(
set({ sendDelaySeconds: 0 });
return;
}
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
return;
}
set({ [key]: settings[key] });
}
});
@@ -852,7 +864,7 @@ if (typeof window !== 'undefined') {
syncWarn('Settings sync endpoint returned 404, disabling sync');
syncEnabled = false;
} else if (res.status === 403) {
// Identity mismatch current session cookies don't match the
// Identity mismatch - current session cookies don't match the
// username/serverUrl we're syncing for (common in dev mock mode where
// no stalwart-context cookie is written, or when rememberMe is off).
// Retrying won't help for this session; disable to stop the noise.
+1 -1
View File
@@ -19,7 +19,7 @@ import {
// Legacy storage key used by an earlier build that persisted unlock passphrases
// in sessionStorage. Wipe on module load so any in-flight tab upgrading to this
// version doesn't leave plaintext key material sitting around. New code never
// writes here unlocked CryptoKey handles live only in the in-memory Map below.
// writes here - unlocked CryptoKey handles live only in the in-memory Map below.
const LEGACY_REMEMBERED_UNLOCKS_KEY = 'smime-unlocked-session';
if (typeof window !== 'undefined') {
try { window.sessionStorage.removeItem(LEGACY_REMEMBERED_UNLOCKS_KEY); } catch { /* ignore */ }