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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ interface LocaleStore {
|
||||
export const useLocaleStore = create<LocaleStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
locale: 'en',
|
||||
locale: '',
|
||||
setLocale: (locale) => set({ locale }),
|
||||
}),
|
||||
{
|
||||
|
||||
+12
-8
@@ -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) {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user