Merge branch 'main' into feature/scheduled-send
This commit is contained in:
+106
-37
@@ -377,27 +377,30 @@ export const useAuthStore = create<AuthState>()(
|
||||
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
||||
await client.connect();
|
||||
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||
initializeFeatureStores(client);
|
||||
|
||||
// Register in account store
|
||||
// Resolve account/slot info up front so writes can start immediately.
|
||||
const accountStore = useAccountStore.getState();
|
||||
const accountId = generateAccountId(username, serverUrl);
|
||||
const cookieSlot = accountStore.hasAccount(username, serverUrl)
|
||||
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
|
||||
: accountStore.getNextCookieSlot();
|
||||
|
||||
// Snapshot current account if switching away and clear stores so
|
||||
// the new account starts with a clean email/contact/calendar state.
|
||||
// Snapshot/clear before kicking off any feature-store fetches so they
|
||||
// don't write into stores we're about to wipe.
|
||||
const prevAccountId = get().activeAccountId;
|
||||
if (prevAccountId && prevAccountId !== accountId) {
|
||||
snapshotAccount(prevAccountId);
|
||||
clearAllStores();
|
||||
}
|
||||
|
||||
// Identities can fly in parallel with everything below. JMAPClient
|
||||
// captures the auth header per-request, so the optional TOTP upgrade
|
||||
// doesn't affect this already-issued request.
|
||||
const identitiesPromise = client.getIdentities();
|
||||
|
||||
// When TOTP was used, try to upgrade to token-based auth so the
|
||||
// session survives TOTP rotation (basic auth embeds the TOTP in
|
||||
// every request, which expires after ~30 seconds).
|
||||
// every request, which expires after ~30 seconds). Must complete
|
||||
// before stalwart-context reads the auth header.
|
||||
let upgradedToOAuth = false;
|
||||
let oauthAccessToken: string | null = null;
|
||||
let oauthExpiresIn = 0;
|
||||
@@ -439,6 +442,29 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
const effectiveAuthMode = upgradedToOAuth ? 'oauth' : 'basic';
|
||||
|
||||
// Run the remaining independent requests in parallel. The session
|
||||
// write and stalwart-context write are best-effort persistence; the
|
||||
// outer login still succeeds even if they log a warning. Errors are
|
||||
// caught locally so Promise.all doesn't reject on either.
|
||||
const sessionWrite: Promise<unknown> = (rememberMe && !upgradedToOAuth)
|
||||
? apiFetch(`/api/auth/session?slot=${cookieSlot}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||
}).then((res) => {
|
||||
if (!res.ok) debug.error('Failed to store session: server returned', res.status);
|
||||
}).catch((err) => debug.error('Failed to store session:', err))
|
||||
: Promise.resolve();
|
||||
|
||||
const [rawIdentities] = await Promise.all([
|
||||
identitiesPromise,
|
||||
sessionWrite,
|
||||
syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot),
|
||||
]);
|
||||
|
||||
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
|
||||
initializeFeatureStores(client);
|
||||
|
||||
// Store client in multi-account map
|
||||
clients.set(accountId, client);
|
||||
bindClientStatusHandlers(client, set, get, accountId);
|
||||
@@ -468,27 +494,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
lastLoginAt: Date.now(),
|
||||
});
|
||||
|
||||
// Store session cookie BEFORE setting isAuthenticated to avoid a race
|
||||
// condition: setting isAuthenticated triggers navigation to the main page,
|
||||
// whose checkAuth() would try to read the cookie before it was stored.
|
||||
if (rememberMe && !upgradedToOAuth) {
|
||||
// For basic auth (no TOTP or TOTP upgrade failed), store encrypted credentials
|
||||
try {
|
||||
const res = await apiFetch(`/api/auth/session?slot=${cookieSlot}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
debug.error('Failed to store session: server returned', res.status);
|
||||
}
|
||||
} catch (err) {
|
||||
debug.error('Failed to store session:', err);
|
||||
}
|
||||
}
|
||||
|
||||
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -507,6 +512,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
// Kick off mailbox/quota/email fetches now so they overlap with the
|
||||
// soft-nav + home-page hydration that follows login. Dynamic import
|
||||
// avoids a static circular dep with email-store.
|
||||
import('@/stores/email-store').then(({ useEmailStore }) => {
|
||||
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
||||
debug.error('Initial data prefetch failed:', err);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
// Schedule token refresh for TOTP-upgraded sessions
|
||||
if (upgradedToOAuth && oauthExpiresIn > 0) {
|
||||
scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId);
|
||||
@@ -704,6 +718,12 @@ export const useAuthStore = create<AuthState>()(
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
import('@/stores/email-store').then(({ useEmailStore }) => {
|
||||
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
||||
debug.error('Initial data prefetch failed:', err);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||
|
||||
notifyParent('sso:auth-success', { username });
|
||||
@@ -750,12 +770,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
const accountStore = useAccountStore.getState();
|
||||
const slot = accountStore.getNextCookieSlot();
|
||||
|
||||
const ssoRes = await apiFetch('/api/auth/sso/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ code, state, slot }),
|
||||
});
|
||||
// SSO token exchange and config fetch are independent - fire both
|
||||
// up front and let them resolve in parallel.
|
||||
const [ssoRes, config] = await Promise.all([
|
||||
apiFetch('/api/auth/sso/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ code, state, slot }),
|
||||
}),
|
||||
fetchConfig(),
|
||||
]);
|
||||
|
||||
if (!ssoRes.ok) {
|
||||
const errorData = await ssoRes.json().catch(() => ({ error: 'token_exchange_failed' }));
|
||||
@@ -764,8 +789,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
const { access_token, expires_in } = await ssoRes.json();
|
||||
|
||||
// We need the server URL from config
|
||||
const config = await fetchConfig();
|
||||
const ssoServerUrl = config.jmapServerUrl;
|
||||
|
||||
if (!ssoServerUrl) {
|
||||
@@ -833,6 +856,12 @@ export const useAuthStore = create<AuthState>()(
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
import('@/stores/email-store').then(({ useEmailStore }) => {
|
||||
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
||||
debug.error('Initial data prefetch failed:', err);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||
|
||||
notifyParent('sso:auth-success', { username });
|
||||
@@ -1217,7 +1246,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
checkAuth: async () => {
|
||||
const accountStore = useAccountStore.getState();
|
||||
const accounts = accountStore.accounts;
|
||||
let accounts = accountStore.accounts;
|
||||
|
||||
// If the only account is the demo account, re-initialize demo mode
|
||||
// instead of trying to restore a server session (which doesn't exist).
|
||||
@@ -1226,6 +1255,46 @@ export const useAuthStore = create<AuthState>()(
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
// the cookies sit unused and the SPA bounces to the login screen.
|
||||
if (accounts.length === 0) {
|
||||
try {
|
||||
const restore = await apiFetch('/api/auth/session', { method: 'PUT' });
|
||||
if (restore.ok) {
|
||||
const data = await restore.json();
|
||||
if (data?.serverUrl && data?.username && data?.password) {
|
||||
// Stalwart master-user impersonation uses "target%master" as
|
||||
// the auth username. The full string must be preserved for
|
||||
// JMAP auth, but the user-facing display (avatar, switcher,
|
||||
// sign-out copy) should only show the target mailbox.
|
||||
const fullUsername: string = data.username;
|
||||
const displayMailbox = fullUsername.includes('%')
|
||||
? fullUsername.split('%', 1)[0]
|
||||
: fullUsername;
|
||||
accountStore.addAccount({
|
||||
label: displayMailbox,
|
||||
serverUrl: data.serverUrl,
|
||||
username: fullUsername,
|
||||
authMode: 'basic',
|
||||
rememberMe: true,
|
||||
displayName: displayMailbox,
|
||||
email: displayMailbox,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: false,
|
||||
hasError: false,
|
||||
isDefault: true,
|
||||
});
|
||||
accounts = useAccountStore.getState().accounts;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
debug.error('Orphan session cookie adoption failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-account restoration: restore all registered accounts
|
||||
if (accounts.length > 0) {
|
||||
// Null out client so the page doesn't fire data-loading effects
|
||||
|
||||
+137
-32
@@ -11,6 +11,11 @@ import { generateUUID } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
||||
|
||||
// In-flight refresh dedup. Concurrent callers (auto-interval +
|
||||
// manual refresh, two account-switch reloads, etc.) share the same
|
||||
// promise instead of double-fetching and racing the diff/import phase.
|
||||
const refreshInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
|
||||
|
||||
const CALENDAR_VIEW_MODES: CalendarViewMode[] = ['month', 'week', 'day', 'agenda', 'tasks'];
|
||||
@@ -97,6 +102,11 @@ export interface ICalSubscription {
|
||||
id: string;
|
||||
url: string;
|
||||
calendarId: string;
|
||||
// The JMAP account this subscription belongs to. Optional for back-
|
||||
// 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;
|
||||
name: string;
|
||||
color: string;
|
||||
refreshInterval: number; // minutes
|
||||
@@ -718,7 +728,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
const cal = get().calendars.find(c => c.id === calendarId);
|
||||
const realCalId = cal?.originalId || calendarId;
|
||||
const targetAccountId = cal?.accountId;
|
||||
let totalDeleted = 0;
|
||||
let totalRemoved = 0;
|
||||
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
@@ -728,13 +738,39 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
const calendarEvents = allEvents.filter(e => e.calendarIds?.[realCalId]);
|
||||
if (calendarEvents.length === 0) break;
|
||||
|
||||
const ids = calendarEvents.map(e => e.id);
|
||||
const { destroyed } = await client.batchDeleteCalendarEvents(ids, targetAccountId);
|
||||
totalDeleted += destroyed.length;
|
||||
// Separate events that live ONLY in this calendar (delete) from
|
||||
// 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> }> = [];
|
||||
for (const e of calendarEvents) {
|
||||
const otherCalIds = { ...(e.calendarIds || {}) };
|
||||
delete otherCalIds[realCalId];
|
||||
if (Object.keys(otherCalIds).length === 0) {
|
||||
idsToDelete.push(e.id);
|
||||
} else {
|
||||
eventsToUnlink.push({ id: e.id, calendarIds: otherCalIds });
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't destroy any events, stop to avoid infinite loop
|
||||
if (destroyed.length === 0) {
|
||||
debug.warn('calendar', 'Could not delete any events, stopping clear loop. Not destroyed:', ids.length);
|
||||
let removedThisPass = 0;
|
||||
if (idsToDelete.length > 0) {
|
||||
const { destroyed } = await client.batchDeleteCalendarEvents(idsToDelete, targetAccountId);
|
||||
removedThisPass += destroyed.length;
|
||||
}
|
||||
for (const { id, calendarIds } of eventsToUnlink) {
|
||||
try {
|
||||
await client.updateCalendarEvent(id, { calendarIds } as Partial<CalendarEvent>, undefined, targetAccountId);
|
||||
removedThisPass++;
|
||||
} catch (err) {
|
||||
debug.warn('calendar', 'Failed to unlink event from cleared calendar:', err);
|
||||
}
|
||||
}
|
||||
totalRemoved += removedThisPass;
|
||||
|
||||
// If we couldn't remove anything, stop to avoid infinite loop
|
||||
if (removedThisPass === 0) {
|
||||
debug.warn('calendar', 'Could not clear any events, stopping. Remaining:', calendarEvents.length);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -745,7 +781,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set((state) => ({
|
||||
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
|
||||
}));
|
||||
return totalDeleted;
|
||||
return totalRemoved;
|
||||
} catch (error) {
|
||||
debug.error('Failed to clear calendar events:', error);
|
||||
set({ error: 'Failed to clear calendar events' });
|
||||
@@ -770,9 +806,13 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
},
|
||||
|
||||
addICalSubscription: async (client, url, name, color, refreshInterval = 60) => {
|
||||
// Normalize webcal(s):// → https:// so the server-side fetcher
|
||||
// (which only accepts http/https) doesn't reject every refresh.
|
||||
const normalizedUrl = url.replace(/^webcals?:\/\//i, 'https://');
|
||||
|
||||
let calendar: Calendar | null = null;
|
||||
try {
|
||||
// Create a new calendar for this subscription
|
||||
const calendar = await client.createCalendar({
|
||||
calendar = await client.createCalendar({
|
||||
name,
|
||||
color,
|
||||
isVisible: true,
|
||||
@@ -782,8 +822,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
|
||||
const subscription: ICalSubscription = {
|
||||
id: generateUUID(),
|
||||
url,
|
||||
url: normalizedUrl,
|
||||
calendarId: calendar.id,
|
||||
accountId: client.getAccountId(),
|
||||
name,
|
||||
color,
|
||||
refreshInterval,
|
||||
@@ -791,22 +832,32 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
calendars: [...state.calendars, calendar],
|
||||
selectedCalendarIds: [...state.selectedCalendarIds, calendar.id],
|
||||
calendars: [...state.calendars, calendar!],
|
||||
selectedCalendarIds: [...state.selectedCalendarIds, calendar!.id],
|
||||
icalSubscriptions: [...state.icalSubscriptions, subscription],
|
||||
}));
|
||||
|
||||
// Do initial fetch
|
||||
try {
|
||||
await get().refreshICalSubscription(client, subscription.id);
|
||||
} catch {
|
||||
// Subscription created, initial fetch failed - user can retry
|
||||
debug.warn('calendar', 'Initial subscription fetch failed for:', name);
|
||||
}
|
||||
// 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);
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
debug.error('Failed to add iCal subscription:', error);
|
||||
if (calendar) {
|
||||
const calendarId = calendar.id;
|
||||
try {
|
||||
await client.deleteCalendar(calendarId);
|
||||
} catch (rollbackErr) {
|
||||
debug.warn('calendar', 'Rollback failed for subscription calendar:', rollbackErr);
|
||||
}
|
||||
set((state) => ({
|
||||
calendars: state.calendars.filter(c => c.id !== calendarId),
|
||||
selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== calendarId),
|
||||
icalSubscriptions: state.icalSubscriptions.filter(s => s.calendarId !== calendarId),
|
||||
events: state.events.filter(e => !e.calendarIds?.[calendarId]),
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
@@ -815,30 +866,35 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
||||
if (!sub) return;
|
||||
|
||||
// Normalize webcal(s):// in the new URL so refreshes don't break.
|
||||
const normalizedUpdates: typeof updates = updates.url
|
||||
? { ...updates, url: updates.url.replace(/^webcals?:\/\//i, 'https://') }
|
||||
: updates;
|
||||
|
||||
// Update the calendar on the server if name or color changed
|
||||
if (updates.name || updates.color) {
|
||||
if (normalizedUpdates.name || normalizedUpdates.color) {
|
||||
const calUpdates: Record<string, unknown> = {};
|
||||
if (updates.name) calUpdates.name = updates.name;
|
||||
if (updates.color) calUpdates.color = updates.color;
|
||||
if (normalizedUpdates.name) calUpdates.name = normalizedUpdates.name;
|
||||
if (normalizedUpdates.color) calUpdates.color = normalizedUpdates.color;
|
||||
await client.updateCalendar(sub.calendarId, calUpdates);
|
||||
}
|
||||
|
||||
// Update local subscription record
|
||||
const updated = { ...sub, ...updates };
|
||||
const updated = { ...sub, ...normalizedUpdates };
|
||||
set((state) => ({
|
||||
icalSubscriptions: state.icalSubscriptions.map(s => s.id === subscriptionId ? updated : s),
|
||||
calendars: state.calendars.map(c => {
|
||||
if (c.id !== sub.calendarId) return c;
|
||||
return {
|
||||
...c,
|
||||
...(updates.name ? { name: updates.name } : {}),
|
||||
...(updates.color ? { color: updates.color } : {}),
|
||||
...(normalizedUpdates.name ? { name: normalizedUpdates.name } : {}),
|
||||
...(normalizedUpdates.color ? { color: normalizedUpdates.color } : {}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// If URL changed, refresh to fetch events from new source
|
||||
if (updates.url && updates.url !== sub.url) {
|
||||
if (normalizedUpdates.url && normalizedUpdates.url !== sub.url) {
|
||||
await get().refreshICalSubscription(client, subscriptionId);
|
||||
}
|
||||
},
|
||||
@@ -863,10 +919,21 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
},
|
||||
|
||||
refreshICalSubscription: async (client, subscriptionId) => {
|
||||
const existing = refreshInFlight.get(subscriptionId);
|
||||
if (existing) return existing;
|
||||
|
||||
const sub = get().icalSubscriptions.find(s => s.id === subscriptionId);
|
||||
if (!sub) return;
|
||||
|
||||
try {
|
||||
// Skip if the subscription is scoped to a different JMAP account
|
||||
// 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 });
|
||||
return;
|
||||
}
|
||||
|
||||
const work = (async () => {
|
||||
const response = await apiFetch('/api/fetch-ical', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -902,13 +969,33 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
// Delete events that are no longer in the feed
|
||||
const idsToDelete = serverEvents
|
||||
.filter(e => !e.uid || !incomingUids.has(e.uid))
|
||||
.map(e => e.id);
|
||||
// Events no longer in the feed. If an event lives only in the
|
||||
// subscription calendar, delete it. If it is also linked to other
|
||||
// calendars (importEvents links by UID), only unlink the
|
||||
// subscription calendar so we don't cascade-delete the user's
|
||||
// personal copy.
|
||||
const staleEvents = serverEvents.filter(e => !e.uid || !incomingUids.has(e.uid));
|
||||
const idsToDelete: string[] = [];
|
||||
const eventsToUnlink: Array<{ id: string; calendarIds: Record<string, boolean> }> = [];
|
||||
for (const e of staleEvents) {
|
||||
const otherCalIds = { ...(e.calendarIds || {}) };
|
||||
delete otherCalIds[sub.calendarId];
|
||||
if (Object.keys(otherCalIds).length === 0) {
|
||||
idsToDelete.push(e.id);
|
||||
} else {
|
||||
eventsToUnlink.push({ id: e.id, calendarIds: otherCalIds });
|
||||
}
|
||||
}
|
||||
if (idsToDelete.length > 0) {
|
||||
await client.batchDeleteCalendarEvents(idsToDelete);
|
||||
}
|
||||
for (const { id, calendarIds } of eventsToUnlink) {
|
||||
try {
|
||||
await client.updateCalendarEvent(id, { calendarIds } as Partial<CalendarEvent>);
|
||||
} catch (err) {
|
||||
debug.warn('calendar', 'Failed to unlink stale event from subscription calendar:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Import only events that don't already exist on server
|
||||
const eventsToImport = parsedEvents.filter(e => !e.uid || !existingByUid.has(e.uid));
|
||||
@@ -931,17 +1018,30 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
s.id === subscriptionId ? { ...s, lastRefreshed: new Date().toISOString() } : s
|
||||
),
|
||||
}));
|
||||
})();
|
||||
|
||||
refreshInFlight.set(subscriptionId, work);
|
||||
try {
|
||||
await work;
|
||||
} catch (error) {
|
||||
debug.error('Failed to refresh iCal subscription:', sub.name, error);
|
||||
throw error;
|
||||
} finally {
|
||||
refreshInFlight.delete(subscriptionId);
|
||||
}
|
||||
},
|
||||
|
||||
refreshAllSubscriptions: async (client) => {
|
||||
const { icalSubscriptions } = get();
|
||||
const currentAccountId = client.getAccountId();
|
||||
const now = Date.now();
|
||||
|
||||
for (const sub of icalSubscriptions) {
|
||||
// Only refresh subs for the current account (or legacy untagged
|
||||
// subs, which are treated as belonging to whichever account the
|
||||
// user has active).
|
||||
if (sub.accountId && sub.accountId !== currentAccountId) continue;
|
||||
|
||||
const lastRefreshed = sub.lastRefreshed ? new Date(sub.lastRefreshed).getTime() : 0;
|
||||
const intervalMs = sub.refreshInterval * 60 * 1000;
|
||||
|
||||
@@ -956,9 +1056,14 @@ 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
|
||||
// here would lose them from localStorage on every switch.
|
||||
const preservedSubs = get().icalSubscriptions;
|
||||
set({
|
||||
...initialState,
|
||||
selectedDate: new Date(),
|
||||
icalSubscriptions: preservedSubs,
|
||||
});
|
||||
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
|
||||
useCalendarNotificationStore.getState().clearAll();
|
||||
|
||||
+34
-3
@@ -93,10 +93,14 @@ interface EmailStore {
|
||||
// JMAP operations
|
||||
fetchMailboxes: (client: IJMAPClient) => Promise<void>;
|
||||
fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
|
||||
// Eager post-login bootstrap: fires mailboxes/quota/emails so the round-trips
|
||||
// overlap with Next's soft-nav + home-page hydration. Safe to call multiple
|
||||
// times; later calls are no-ops while a prior one is in flight.
|
||||
prefetchInitialData: (client: IJMAPClient) => Promise<void>;
|
||||
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string) => Promise<SendEmailResult>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string) => Promise<SendEmailResult>;
|
||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string) => Promise<SendEmailResult>;
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
@@ -424,6 +428,33 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
prefetchInitialData: async (client) => {
|
||||
// Coalesce overlapping callers (e.g. login() and a slow home-page useEffect
|
||||
// racing for the same fetch). The promise is stashed on the client so we
|
||||
// don't need a separate keyed map and stale entries can't outlive the client.
|
||||
const target = client as IJMAPClient & { __prefetchPromise?: Promise<void> };
|
||||
if (target.__prefetchPromise) return target.__prefetchPromise;
|
||||
target.__prefetchPromise = (async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
get().fetchMailboxes(client),
|
||||
get().fetchQuota(client),
|
||||
]);
|
||||
const { selectedMailbox } = get();
|
||||
if (selectedMailbox) {
|
||||
await get().fetchEmails(client, selectedMailbox);
|
||||
} else {
|
||||
await get().fetchEmails(client);
|
||||
}
|
||||
// Tag counts can finish whenever; don't block the prefetch on them.
|
||||
void get().fetchTagCounts(client);
|
||||
} finally {
|
||||
delete target.__prefetchPromise;
|
||||
}
|
||||
})();
|
||||
return target.__prefetchPromise;
|
||||
},
|
||||
|
||||
fetchEmails: async (client, mailboxId) => {
|
||||
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
|
||||
try {
|
||||
@@ -621,10 +652,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil) => {
|
||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil);
|
||||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom);
|
||||
// Refresh handled by UI layer for immediate feedback
|
||||
set({
|
||||
isLoading: false,
|
||||
|
||||
+132
-65
@@ -1,37 +1,20 @@
|
||||
// Plugin store - manages installed plugins, slot registrations, and lifecycle
|
||||
// Plugin store - manages installed plugins and lifecycle. Slot registrations
|
||||
// are owned by `lib/plugin-sandbox/registry` (per-iframe), not by the store.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type {
|
||||
InstalledPlugin,
|
||||
PluginStatus,
|
||||
SlotName,
|
||||
SlotRegistration,
|
||||
Disposable,
|
||||
} from '@/lib/plugin-types';
|
||||
import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { extractPlugin } from '@/lib/plugin-validator';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader';
|
||||
import { setSlotRegistrationBridge } from '@/lib/plugin-api';
|
||||
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||
import { requestConsent } from '@/lib/plugin-sandbox/consent';
|
||||
import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity';
|
||||
import { verifySignature } from '@/lib/plugin-sandbox/bundle-signing';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
// ─── Slot State ──────────────────────────────────────────────
|
||||
|
||||
const SLOT_NAMES: SlotName[] = [
|
||||
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar', 'composer-sidebar-right',
|
||||
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
|
||||
'calendar-event-actions', 'admin-plugin-page',
|
||||
];
|
||||
|
||||
function emptySlots(): Record<SlotName, SlotRegistration[]> {
|
||||
const slots = {} as Record<SlotName, SlotRegistration[]>;
|
||||
for (const name of SLOT_NAMES) {
|
||||
slots[name] = [];
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
|
||||
import type { Permission } from '@/lib/plugin-types';
|
||||
|
||||
let pluginInitializationPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -39,7 +22,6 @@ let pluginInitializationPromise: Promise<void> | null = null;
|
||||
|
||||
interface PluginStoreState {
|
||||
plugins: InstalledPlugin[];
|
||||
slots: Record<SlotName, SlotRegistration[]>;
|
||||
initialized: boolean;
|
||||
|
||||
// Management
|
||||
@@ -49,8 +31,7 @@ interface PluginStoreState {
|
||||
disablePlugin: (id: string) => void;
|
||||
updatePluginSettings: (id: string, settings: Record<string, unknown>) => void;
|
||||
|
||||
// Runtime (called by plugin loader / API bridge)
|
||||
registerSlot: (slotName: SlotName, registration: SlotRegistration) => Disposable;
|
||||
// Runtime (called by plugin loader)
|
||||
setPluginStatus: (id: string, status: PluginStatus, error?: string) => void;
|
||||
|
||||
// Init
|
||||
@@ -63,7 +44,6 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
plugins: [],
|
||||
slots: emptySlots(),
|
||||
initialized: false,
|
||||
|
||||
installPlugin: async (file: File) => {
|
||||
@@ -82,6 +62,10 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
deactivatePlugin(manifest.id);
|
||||
}
|
||||
|
||||
// Compute bundleHash so the admin-approval gate can pin to this
|
||||
// specific bundle (server-side state keys on (id, hash) pairs).
|
||||
const bundleHash = await sha256Hex(code).catch(() => undefined);
|
||||
|
||||
const plugin: InstalledPlugin = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
@@ -98,9 +82,13 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
adminApproved: false, // Requires admin approval before it can be enabled
|
||||
settings: existing?.settings ?? {},
|
||||
settingsSchema: manifest.settingsSchema,
|
||||
...(bundleHash ? { bundleHash } : {}),
|
||||
...(manifest.httpOrigins && manifest.httpOrigins.length > 0
|
||||
? { httpOrigins: manifest.httpOrigins }
|
||||
: {}),
|
||||
...(manifest.apiPostPaths && manifest.apiPostPaths.length > 0
|
||||
? { apiPostPaths: manifest.apiPostPaths }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Save code to IndexedDB
|
||||
@@ -149,20 +137,66 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (!plugin) return;
|
||||
|
||||
// Block enabling if plugin requires admin approval and hasn't been approved
|
||||
// Admin approval gate. Managed (admin-pushed) plugins are pre-
|
||||
// approved. For user-installed plugins the server-side state is
|
||||
// authoritative: the client-only `isPluginApproved` flag is kept as
|
||||
// a fast-path hint but the server result wins.
|
||||
const requireApproval = usePolicyStore.getState().isFeatureEnabled('requirePluginApproval');
|
||||
const isApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id);
|
||||
if (requireApproval && !isApproved) return;
|
||||
const policyApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id);
|
||||
if (requireApproval && !policyApproved && plugin.bundleHash) {
|
||||
const status = await checkServerApproval(plugin.id, plugin.bundleHash).catch(() => null);
|
||||
if (status?.status === 'approved') {
|
||||
// Approval available; proceed.
|
||||
} else if (status?.status === 'denied') {
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === id ? { ...p, status: 'error' as PluginStatus, error: 'Plugin denied by administrator' } : p
|
||||
),
|
||||
}));
|
||||
return;
|
||||
} else {
|
||||
// 'pending' or 'not-requested' — submit a request and refuse to enable.
|
||||
await submitApprovalRequest(plugin).catch(() => { /* best effort */ });
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === id ? { ...p, status: 'error' as PluginStatus, error: 'Awaiting administrator approval' } : p
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
} else if (requireApproval && !policyApproved) {
|
||||
// No bundleHash means we can't pin the approval — refuse.
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure bridges are wired before loading (may not have run initializePlugins yet)
|
||||
// Per-user consent gate: prompt for any permission the user has not
|
||||
// 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 ?? []);
|
||||
const missing = (plugin.permissions ?? [])
|
||||
.filter((p): p is Permission => !!p)
|
||||
.filter((p) => !implicit.has(p) && !granted.has(p));
|
||||
if (missing.length > 0 && !plugin.managed) {
|
||||
const accepted = await requestConsent(plugin.id, plugin.name, missing as Permission[]);
|
||||
if (!accepted) return;
|
||||
// Persist the grants so future enables don't re-prompt.
|
||||
const allGranted = [...new Set<string>([...granted, ...missing])];
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === id ? { ...p, grantedPermissions: allGranted } : p
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
// Ensure bridge is wired before loading (may not have run initializePlugins yet)
|
||||
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
|
||||
setSlotRegistrationBridge(get().registerSlot);
|
||||
|
||||
set({
|
||||
plugins: plugins.map(p =>
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p
|
||||
),
|
||||
});
|
||||
}));
|
||||
|
||||
// Load it immediately
|
||||
const updatedPlugin = get().plugins.find(p => p.id === id);
|
||||
@@ -196,29 +230,6 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
});
|
||||
},
|
||||
|
||||
registerSlot: (slotName: SlotName, registration: SlotRegistration): Disposable => {
|
||||
set(state => ({
|
||||
slots: {
|
||||
...state.slots,
|
||||
[slotName]: [
|
||||
...state.slots[slotName],
|
||||
registration,
|
||||
].sort((a, b) => a.order - b.order),
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
set(state => ({
|
||||
slots: {
|
||||
...state.slots,
|
||||
[slotName]: state.slots[slotName].filter(r => r !== registration),
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
setPluginStatus: (id: string, status: PluginStatus, error?: string) => {
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
@@ -246,7 +257,6 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
setPluginStoreAccessor({
|
||||
setPluginStatus: get().setPluginStatus,
|
||||
});
|
||||
setSlotRegistrationBridge(get().registerSlot);
|
||||
setupAutoDisable();
|
||||
|
||||
// Sync server-managed plugins before loading
|
||||
@@ -277,15 +287,12 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
status: p.enabled ? 'enabled' : 'installed',
|
||||
error: undefined,
|
||||
})),
|
||||
// Don't persist slots - they are runtime-only, rebuilt on load
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
state.plugins = markServerManagedPlugins(state.plugins);
|
||||
state.plugins = dedupeInstalledPlugins(state.plugins);
|
||||
// Ensure slots are initialized after rehydration
|
||||
state.slots = emptySlots();
|
||||
state.initialized = false;
|
||||
}
|
||||
};
|
||||
@@ -313,6 +320,8 @@ interface ServerPluginInfo {
|
||||
dev?: boolean;
|
||||
/** Allowlist of origins this plugin may target via api.http.fetch(). */
|
||||
httpOrigins?: string[];
|
||||
/** Allowlist of same-origin /api/* paths this plugin may target via api.http.post(). */
|
||||
apiPostPaths?: string[];
|
||||
/** Per-user settings schema, captured from the manifest server-side. */
|
||||
settingsSchema?: InstalledPlugin['settingsSchema'];
|
||||
}
|
||||
@@ -419,6 +428,9 @@ async function syncServerPlugins(
|
||||
...(sp.httpOrigins && sp.httpOrigins.length > 0
|
||||
? { httpOrigins: sp.httpOrigins }
|
||||
: {}),
|
||||
...(sp.apiPostPaths && sp.apiPostPaths.length > 0
|
||||
? { apiPostPaths: sp.apiPostPaths }
|
||||
: {}),
|
||||
};
|
||||
|
||||
set(state => {
|
||||
@@ -455,6 +467,7 @@ async function syncServerPlugins(
|
||||
forceEnabled: sp.forceEnabled,
|
||||
bundleHash: sp.bundleHash,
|
||||
httpOrigins: sp.httpOrigins,
|
||||
apiPostPaths: sp.apiPostPaths,
|
||||
settingsSchema: sp.settingsSchema,
|
||||
}
|
||||
: p
|
||||
@@ -528,9 +541,63 @@ async function downloadPluginBundle(pluginId: string, bundleHash?: string): Prom
|
||||
const suffix = bundleHash ? `?v=${encodeURIComponent(bundleHash)}` : '';
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle${suffix}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
const code = await res.text();
|
||||
// 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
|
||||
// at load time still catches transport corruption.
|
||||
const sig = res.headers.get('X-Bundle-Signature');
|
||||
if (sig) {
|
||||
const ok = await verifySignature(code, sig);
|
||||
if (!ok) {
|
||||
console.error(`[plugin-store] Refusing bundle for "${pluginId}": signature verification failed`);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
console.warn(`[plugin-store] Bundle for "${pluginId}" has no Ed25519 signature; loading without it`);
|
||||
}
|
||||
return code;
|
||||
} catch {
|
||||
console.warn(`[plugin-store] Failed to download bundle for plugin "${pluginId}"`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server-side admin-approval helpers ───────────────────────
|
||||
|
||||
async function checkServerApproval(pluginId: string, bundleHash: string): Promise<{ status: 'pending' | 'approved' | 'denied' | 'not-requested' } | null> {
|
||||
try {
|
||||
const url = `/api/plugin-approval-status?pluginId=${encodeURIComponent(pluginId)}&bundleHash=${encodeURIComponent(bundleHash)}`;
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitApprovalRequest(plugin: InstalledPlugin): Promise<void> {
|
||||
if (!plugin.bundleHash) return;
|
||||
try {
|
||||
await apiFetch('/api/plugin-approval-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pluginId: plugin.id,
|
||||
bundleHash: plugin.bundleHash,
|
||||
manifest: {
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
author: plugin.author,
|
||||
description: plugin.description,
|
||||
permissions: plugin.permissions,
|
||||
httpOrigins: plugin.httpOrigins,
|
||||
apiPostPaths: plugin.apiPostPaths,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
|
||||
export type ProTabKind =
|
||||
| 'mail' | 'calendar' | 'contacts' | 'files' | 'settings'
|
||||
| 'compose' | 'email';
|
||||
|
||||
export type ProPaneId = 'main' | 'split';
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type ProSplitOrientation = 'vertical';
|
||||
|
||||
export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
from?: { email?: string; name?: string }[];
|
||||
replyToAddresses?: { email?: string; name?: string }[];
|
||||
to?: { email?: string; name?: string }[];
|
||||
cc?: { email?: string; name?: string }[];
|
||||
bcc?: { email?: string; name?: string }[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
htmlBody?: string;
|
||||
receivedAt?: string;
|
||||
accountId?: string;
|
||||
attachments?: Array<{
|
||||
blobId: string; name?: string; type: string; size: number;
|
||||
cid?: string; disposition?: string;
|
||||
}>;
|
||||
messageId?: string;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
quoteHeaderHtml?: string;
|
||||
quoteHeaderText?: string;
|
||||
quoteWrapInBlockquote?: boolean;
|
||||
}
|
||||
|
||||
export interface ProComposeTabData {
|
||||
sessionId: number;
|
||||
mode: ProComposerMode;
|
||||
replyTo?: ProReplyContext;
|
||||
initialDraftText?: string;
|
||||
initialData?: ComposerDraftData | null;
|
||||
sourceEmailId?: string | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ProEmailTabData {
|
||||
accountId: string;
|
||||
emailId: string;
|
||||
mailboxId: string | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ProTab {
|
||||
id: string;
|
||||
kind: ProTabKind;
|
||||
/** i18n key under `sidebar.*` for built-in app tabs. Empty for compose/email. */
|
||||
labelKey: string;
|
||||
title?: string;
|
||||
closeable: boolean;
|
||||
composeData?: ProComposeTabData;
|
||||
emailData?: ProEmailTabData;
|
||||
/** Which pane this tab lives in. Defaults to 'main' for the single-pane case. */
|
||||
paneId: ProPaneId;
|
||||
}
|
||||
|
||||
interface ProTabState {
|
||||
tabs: ProTab[];
|
||||
/** Active tab in each pane. `split` is null when there is no split. */
|
||||
activeTabId: string;
|
||||
activeSplitTabId: string | null;
|
||||
/** When the user last clicked into a tab/body, which pane was it? */
|
||||
focusedPaneId: ProPaneId;
|
||||
splitOrientation: ProSplitOrientation | null;
|
||||
loadedTabIds: string[];
|
||||
|
||||
openTab: (kind: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => string;
|
||||
openComposeTab: (data: ProComposeTabData) => string;
|
||||
openEmailTab: (data: ProEmailTabData) => string;
|
||||
closeTab: (id: string) => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
setFocusedPane: (paneId: ProPaneId) => void;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* works both within a pane and across panes (cross-pane drops move the
|
||||
* tab to the target pane).
|
||||
*/
|
||||
reorderTab: (draggedId: string, targetTabId: string, edge: 'before' | 'after') => void;
|
||||
|
||||
/**
|
||||
* Move a tab to a specific pane. If moving into `split` and no split
|
||||
* exists, opens a new split using the supplied orientation.
|
||||
*/
|
||||
moveTabToPane: (
|
||||
tabId: string,
|
||||
paneId: ProPaneId,
|
||||
orientation?: ProSplitOrientation,
|
||||
) => void;
|
||||
|
||||
/** Collapse the split: every split-pane tab returns to main. */
|
||||
collapseSplit: () => void;
|
||||
|
||||
updateTabTitle: (id: string, title: string) => void;
|
||||
updateComposeDraft: (id: string, draft: ComposerDraftData) => void;
|
||||
}
|
||||
|
||||
const TAB_BLUEPRINTS: Record<'mail' | 'calendar' | 'contacts' | 'files' | 'settings', { labelKey: string }> = {
|
||||
mail: { labelKey: 'mail' },
|
||||
calendar: { labelKey: 'calendar' },
|
||||
contacts: { labelKey: 'contacts' },
|
||||
files: { labelKey: 'files' },
|
||||
settings: { labelKey: 'settings' },
|
||||
};
|
||||
|
||||
const HOME_TAB: ProTab = {
|
||||
id: 'home-mail',
|
||||
kind: 'mail',
|
||||
labelKey: TAB_BLUEPRINTS.mail.labelKey,
|
||||
closeable: false,
|
||||
paneId: 'main',
|
||||
};
|
||||
|
||||
function makeId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `pro-tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function neighborInPane(tabs: ProTab[], removedId: string, paneId: ProPaneId): string | null {
|
||||
const inPane = tabs.filter((t) => t.paneId === paneId);
|
||||
const idx = inPane.findIndex((t) => t.id === removedId);
|
||||
if (idx === -1) return inPane[0]?.id ?? null;
|
||||
return (inPane[idx + 1] ?? inPane[idx - 1])?.id ?? null;
|
||||
}
|
||||
|
||||
export const useProTabStore = create<ProTabState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [HOME_TAB],
|
||||
activeTabId: HOME_TAB.id,
|
||||
activeSplitTabId: null,
|
||||
focusedPaneId: 'main',
|
||||
splitOrientation: null,
|
||||
loadedTabIds: [HOME_TAB.id],
|
||||
|
||||
openTab: (kind) => {
|
||||
const state = get();
|
||||
const targetPane = state.focusedPaneId;
|
||||
const existing = state.tabs.find((tab) => tab.kind === kind && tab.paneId === targetPane);
|
||||
if (existing) {
|
||||
if (targetPane === 'main') {
|
||||
set({
|
||||
activeTabId: existing.id,
|
||||
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||
? state.loadedTabIds
|
||||
: [...state.loadedTabIds, existing.id],
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
activeSplitTabId: existing.id,
|
||||
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||
? state.loadedTabIds
|
||||
: [...state.loadedTabIds, existing.id],
|
||||
});
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
const blueprint = TAB_BLUEPRINTS[kind];
|
||||
const newTab: ProTab = {
|
||||
id: makeId(),
|
||||
kind,
|
||||
labelKey: blueprint.labelKey,
|
||||
closeable: true,
|
||||
paneId: targetPane,
|
||||
};
|
||||
set({
|
||||
tabs: [...state.tabs, newTab],
|
||||
...(targetPane === 'main'
|
||||
? { activeTabId: newTab.id }
|
||||
: { activeSplitTabId: newTab.id }),
|
||||
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
||||
});
|
||||
return newTab.id;
|
||||
},
|
||||
|
||||
openComposeTab: (data) => {
|
||||
const state = get();
|
||||
const targetPane = state.focusedPaneId;
|
||||
const newTab: ProTab = {
|
||||
id: makeId(),
|
||||
kind: 'compose',
|
||||
labelKey: '',
|
||||
title: data.title,
|
||||
closeable: true,
|
||||
composeData: data,
|
||||
paneId: targetPane,
|
||||
};
|
||||
set({
|
||||
tabs: [...state.tabs, newTab],
|
||||
...(targetPane === 'main'
|
||||
? { activeTabId: newTab.id }
|
||||
: { activeSplitTabId: newTab.id }),
|
||||
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
||||
});
|
||||
return newTab.id;
|
||||
},
|
||||
|
||||
openEmailTab: (data) => {
|
||||
const state = get();
|
||||
const targetPane = state.focusedPaneId;
|
||||
const existing = state.tabs.find(
|
||||
(tab) => tab.kind === 'email'
|
||||
&& tab.emailData?.emailId === data.emailId
|
||||
&& tab.emailData?.accountId === data.accountId
|
||||
);
|
||||
if (existing) {
|
||||
// Focus the existing email tab in its current pane.
|
||||
if (existing.paneId === 'main') {
|
||||
set({
|
||||
activeTabId: existing.id,
|
||||
focusedPaneId: 'main',
|
||||
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||
? state.loadedTabIds
|
||||
: [...state.loadedTabIds, existing.id],
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
activeSplitTabId: existing.id,
|
||||
focusedPaneId: 'split',
|
||||
loadedTabIds: state.loadedTabIds.includes(existing.id)
|
||||
? state.loadedTabIds
|
||||
: [...state.loadedTabIds, existing.id],
|
||||
});
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
const newTab: ProTab = {
|
||||
id: makeId(),
|
||||
kind: 'email',
|
||||
labelKey: '',
|
||||
title: data.title,
|
||||
closeable: true,
|
||||
emailData: data,
|
||||
paneId: targetPane,
|
||||
};
|
||||
set({
|
||||
tabs: [...state.tabs, newTab],
|
||||
...(targetPane === 'main'
|
||||
? { activeTabId: newTab.id }
|
||||
: { activeSplitTabId: newTab.id }),
|
||||
loadedTabIds: [...state.loadedTabIds, newTab.id],
|
||||
});
|
||||
return newTab.id;
|
||||
},
|
||||
|
||||
closeTab: (id) => {
|
||||
const state = get();
|
||||
const tab = state.tabs.find((t) => t.id === id);
|
||||
if (!tab || !tab.closeable) return;
|
||||
|
||||
const removedPane = tab.paneId;
|
||||
const newTabs = state.tabs.filter((t) => t.id !== id);
|
||||
const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);
|
||||
|
||||
let activeTabId = state.activeTabId;
|
||||
let activeSplitTabId = state.activeSplitTabId;
|
||||
let splitOrientation = state.splitOrientation;
|
||||
let focusedPaneId = state.focusedPaneId;
|
||||
|
||||
if (removedPane === 'main' && state.activeTabId === id) {
|
||||
activeTabId = neighborInPane(newTabs, id, 'main') ?? HOME_TAB.id;
|
||||
}
|
||||
if (removedPane === 'split' && state.activeSplitTabId === id) {
|
||||
activeSplitTabId = neighborInPane(newTabs, id, 'split');
|
||||
}
|
||||
|
||||
// If the split pane is empty, collapse the split.
|
||||
const stillSplit = newTabs.some((t) => t.paneId === 'split');
|
||||
if (!stillSplit) {
|
||||
activeSplitTabId = null;
|
||||
splitOrientation = null;
|
||||
focusedPaneId = 'main';
|
||||
}
|
||||
|
||||
// Guard: never let the tab list be fully empty.
|
||||
if (newTabs.length === 0) {
|
||||
set({
|
||||
tabs: [HOME_TAB],
|
||||
activeTabId: HOME_TAB.id,
|
||||
activeSplitTabId: null,
|
||||
splitOrientation: null,
|
||||
focusedPaneId: 'main',
|
||||
loadedTabIds: [HOME_TAB.id],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the chosen active tab is loaded.
|
||||
const ensureLoaded = (loaded: string[], id: string | null) =>
|
||||
id && !loaded.includes(id) ? [...loaded, id] : loaded;
|
||||
const loaded = ensureLoaded(ensureLoaded(newLoaded, activeTabId), activeSplitTabId);
|
||||
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeTabId,
|
||||
activeSplitTabId,
|
||||
splitOrientation,
|
||||
focusedPaneId,
|
||||
loadedTabIds: loaded,
|
||||
});
|
||||
},
|
||||
|
||||
setActiveTab: (id) => {
|
||||
const state = get();
|
||||
const tab = state.tabs.find((t) => t.id === id);
|
||||
if (!tab) return;
|
||||
const loaded = state.loadedTabIds.includes(id)
|
||||
? state.loadedTabIds
|
||||
: [...state.loadedTabIds, id];
|
||||
if (tab.paneId === 'main') {
|
||||
if (state.activeTabId === id && state.focusedPaneId === 'main') return;
|
||||
set({ activeTabId: id, focusedPaneId: 'main', loadedTabIds: loaded });
|
||||
} else {
|
||||
if (state.activeSplitTabId === id && state.focusedPaneId === 'split') return;
|
||||
set({ activeSplitTabId: id, focusedPaneId: 'split', loadedTabIds: loaded });
|
||||
}
|
||||
},
|
||||
|
||||
setFocusedPane: (paneId) => {
|
||||
const state = get();
|
||||
if (state.focusedPaneId === paneId) return;
|
||||
// Switching focus to the split pane is only meaningful when it exists.
|
||||
if (paneId === 'split' && state.splitOrientation === null) return;
|
||||
set({ focusedPaneId: paneId });
|
||||
},
|
||||
|
||||
reorderTab: (draggedId, targetTabId, edge) => {
|
||||
const state = get();
|
||||
if (draggedId === targetTabId) return;
|
||||
const dragged = state.tabs.find((t) => t.id === draggedId);
|
||||
const target = state.tabs.find((t) => t.id === targetTabId);
|
||||
if (!dragged || !target) return;
|
||||
|
||||
const next = state.tabs.filter((t) => t.id !== draggedId);
|
||||
const insertAt = next.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
|
||||
const reassigned: ProTab = dragged.paneId === target.paneId
|
||||
? dragged
|
||||
: { ...dragged, paneId: target.paneId };
|
||||
next.splice(insertAt, 0, reassigned);
|
||||
|
||||
// If the dragged tab was active in its old pane and just moved to a
|
||||
// different pane, fix up the active ids so the empty side doesn't
|
||||
// hang on to a stale id.
|
||||
const patch: Partial<ProTabState> = { tabs: next };
|
||||
if (dragged.paneId !== target.paneId) {
|
||||
if (dragged.paneId === 'main' && state.activeTabId === draggedId) {
|
||||
patch.activeTabId = neighborInPane(next, draggedId, 'main') ?? HOME_TAB.id;
|
||||
}
|
||||
if (dragged.paneId === 'split' && state.activeSplitTabId === draggedId) {
|
||||
patch.activeSplitTabId = neighborInPane(next, draggedId, 'split');
|
||||
}
|
||||
// Make the dragged tab active in its new home.
|
||||
if (target.paneId === 'main') {
|
||||
patch.activeTabId = draggedId;
|
||||
patch.focusedPaneId = 'main';
|
||||
} else {
|
||||
patch.activeSplitTabId = draggedId;
|
||||
patch.focusedPaneId = 'split';
|
||||
}
|
||||
// Collapse the split if it just emptied.
|
||||
const stillSplit = next.some((t) => t.paneId === 'split');
|
||||
if (!stillSplit) {
|
||||
patch.activeSplitTabId = null;
|
||||
patch.splitOrientation = null;
|
||||
patch.focusedPaneId = 'main';
|
||||
}
|
||||
}
|
||||
set(patch);
|
||||
},
|
||||
|
||||
moveTabToPane: (tabId, paneId, orientation) => {
|
||||
const state = get();
|
||||
const tab = state.tabs.find((t) => t.id === tabId);
|
||||
if (!tab) return;
|
||||
if (tab.paneId === paneId) return;
|
||||
|
||||
// The home tab can move freely (and the unsplit guard below restores
|
||||
// sanity), but if moving it would leave main empty we prevent it.
|
||||
const movingFromMain = tab.paneId === 'main';
|
||||
if (movingFromMain) {
|
||||
const otherMainTabs = state.tabs.filter((t) => t.paneId === 'main' && t.id !== tabId);
|
||||
if (otherMainTabs.length === 0) return; // refuse to empty main
|
||||
}
|
||||
|
||||
const newTabs = state.tabs.map((t) => t.id === tabId ? { ...t, paneId } : t);
|
||||
|
||||
const patch: Partial<ProTabState> = { tabs: newTabs };
|
||||
|
||||
if (paneId === 'split') {
|
||||
// Creating or extending a split.
|
||||
patch.splitOrientation = state.splitOrientation ?? orientation ?? 'vertical';
|
||||
patch.activeSplitTabId = tabId;
|
||||
patch.focusedPaneId = 'split';
|
||||
// If main lost its active tab, pick a neighbor.
|
||||
if (state.activeTabId === tabId) {
|
||||
patch.activeTabId = neighborInPane(newTabs, tabId, 'main') ?? HOME_TAB.id;
|
||||
}
|
||||
} else {
|
||||
patch.activeTabId = tabId;
|
||||
patch.focusedPaneId = 'main';
|
||||
if (state.activeSplitTabId === tabId) {
|
||||
patch.activeSplitTabId = neighborInPane(newTabs, tabId, 'split');
|
||||
}
|
||||
// Collapse if split just emptied.
|
||||
const stillSplit = newTabs.some((t) => t.paneId === 'split');
|
||||
if (!stillSplit) {
|
||||
patch.activeSplitTabId = null;
|
||||
patch.splitOrientation = null;
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = state.loadedTabIds.includes(tabId)
|
||||
? state.loadedTabIds
|
||||
: [...state.loadedTabIds, tabId];
|
||||
patch.loadedTabIds = loaded;
|
||||
|
||||
set(patch);
|
||||
},
|
||||
|
||||
collapseSplit: () => {
|
||||
const state = get();
|
||||
if (state.splitOrientation === null) return;
|
||||
const newTabs = state.tabs.map((t) =>
|
||||
t.paneId === 'split' ? { ...t, paneId: 'main' as const } : t
|
||||
);
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeSplitTabId: null,
|
||||
splitOrientation: null,
|
||||
focusedPaneId: 'main',
|
||||
});
|
||||
},
|
||||
|
||||
updateTabTitle: (id, title) => {
|
||||
const state = get();
|
||||
const tabs = state.tabs.map((tab) =>
|
||||
tab.id === id ? { ...tab, title } : tab
|
||||
);
|
||||
set({ tabs });
|
||||
},
|
||||
|
||||
updateComposeDraft: (id, draft) => {
|
||||
const state = get();
|
||||
const tabs = state.tabs.map((tab) => {
|
||||
if (tab.id !== id || tab.kind !== 'compose' || !tab.composeData) return tab;
|
||||
return {
|
||||
...tab,
|
||||
composeData: { ...tab.composeData, initialData: draft },
|
||||
};
|
||||
});
|
||||
set({ tabs });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'pro-tabs',
|
||||
version: 3,
|
||||
// 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) => ({
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.kind !== 'compose')
|
||||
.map((tab) => tab.kind === 'compose'
|
||||
? { ...tab, composeData: undefined }
|
||||
: tab),
|
||||
activeTabId: state.activeTabId,
|
||||
activeSplitTabId: state.activeSplitTabId,
|
||||
splitOrientation: state.splitOrientation,
|
||||
focusedPaneId: state.focusedPaneId,
|
||||
loadedTabIds: state.loadedTabIds,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
// Backfill paneId in case the user upgrades from version 2.
|
||||
state.tabs = state.tabs.map((tab) => tab.paneId ? tab : { ...tab, paneId: 'main' as const });
|
||||
if (state.tabs.length === 0) {
|
||||
state.tabs = [HOME_TAB];
|
||||
state.activeTabId = HOME_TAB.id;
|
||||
state.activeSplitTabId = null;
|
||||
state.splitOrientation = null;
|
||||
state.focusedPaneId = 'main';
|
||||
state.loadedTabIds = [HOME_TAB.id];
|
||||
return;
|
||||
}
|
||||
if (!state.tabs.some((t) => t.id === state.activeTabId && t.paneId === 'main')) {
|
||||
state.activeTabId = state.tabs.find((t) => t.paneId === 'main')?.id ?? HOME_TAB.id;
|
||||
}
|
||||
if (state.activeSplitTabId !== null && !state.tabs.some((t) => t.id === state.activeSplitTabId && t.paneId === 'split')) {
|
||||
state.activeSplitTabId = state.tabs.find((t) => t.paneId === 'split')?.id ?? null;
|
||||
}
|
||||
if (state.activeSplitTabId === null) {
|
||||
state.splitOrientation = null;
|
||||
state.focusedPaneId = 'main';
|
||||
}
|
||||
if (!state.loadedTabIds.includes(state.activeTabId)) {
|
||||
state.loadedTabIds = [...state.loadedTabIds, state.activeTabId];
|
||||
}
|
||||
if (state.activeSplitTabId && !state.loadedTabIds.includes(state.activeSplitTabId)) {
|
||||
state.loadedTabIds = [...state.loadedTabIds, state.activeSplitTabId];
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -30,6 +30,7 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
export type ListDensity = Density;
|
||||
export type DeleteAction = 'trash' | 'permanent';
|
||||
export type ReplyMode = 'reply' | 'replyAll';
|
||||
export type SignaturePosition = 'above_quote' | 'below_quote';
|
||||
export type DateFormat = 'regional' | 'iso' | 'custom';
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
@@ -38,9 +39,10 @@ export type MailAttachmentAction = 'preview' | 'download';
|
||||
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||
export type ToolbarPosition = 'top' | 'below-subject';
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
export type MailLayout = 'split' | 'focus';
|
||||
export type MailLayout = 'split' | 'focus' | 'horizontal';
|
||||
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
|
||||
export type SendDelaySeconds = 0 | 10 | 30 | 60;
|
||||
export type ProtocolOpenMode = 'active-session' | 'new-tab';
|
||||
|
||||
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
|
||||
export type HoverActionsMode = 'inline' | 'floating';
|
||||
@@ -145,6 +147,8 @@ interface SettingsState {
|
||||
plainTextMode: boolean; // Send plain text only (no rich text editor)
|
||||
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
|
||||
sendDelaySeconds: SendDelaySeconds;
|
||||
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
|
||||
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: number; // minutes (0 = never)
|
||||
@@ -175,6 +179,9 @@ interface SettingsState {
|
||||
emailNotificationSound: boolean;
|
||||
notificationSoundChoice: NotificationSoundChoice;
|
||||
|
||||
// Protocol Handlers
|
||||
protocolOpenMode: ProtocolOpenMode;
|
||||
|
||||
// Calendar Notifications
|
||||
calendarNotificationsEnabled: boolean;
|
||||
calendarNotificationSound: boolean;
|
||||
@@ -185,6 +192,7 @@ interface SettingsState {
|
||||
showToolbarLabels: boolean;
|
||||
hideAccountSwitcher: boolean;
|
||||
showRailAccountList: boolean;
|
||||
proInterface: boolean;
|
||||
|
||||
// Unified Mailbox
|
||||
enableUnifiedMailbox: boolean;
|
||||
@@ -220,6 +228,11 @@ interface SettingsState {
|
||||
sidebarApps: SidebarApp[];
|
||||
keepAppsLoaded: boolean;
|
||||
|
||||
// Onboarding
|
||||
onboardingCompleted: boolean; // Welcome banner dismissed
|
||||
tourCompleted: boolean; // Interactive tour completed
|
||||
showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
debugCategories: Record<DebugCategory, boolean>;
|
||||
@@ -298,6 +311,8 @@ const DEFAULT_SETTINGS = {
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
sendDelaySeconds: 0 as SendDelaySeconds,
|
||||
signaturePosition: 'below_quote' as SignaturePosition,
|
||||
signatureSeparatorEnabled: true,
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: 0, // Never
|
||||
@@ -328,6 +343,9 @@ const DEFAULT_SETTINGS = {
|
||||
emailNotificationSound: true,
|
||||
notificationSoundChoice: 'default' as NotificationSoundChoice,
|
||||
|
||||
// Protocol Handlers
|
||||
protocolOpenMode: 'new-tab' as ProtocolOpenMode,
|
||||
|
||||
// Calendar Notifications
|
||||
calendarNotificationsEnabled: true,
|
||||
calendarNotificationSound: true,
|
||||
@@ -338,6 +356,7 @@ const DEFAULT_SETTINGS = {
|
||||
showToolbarLabels: true,
|
||||
hideAccountSwitcher: false,
|
||||
showRailAccountList: false,
|
||||
proInterface: false,
|
||||
|
||||
// Unified Mailbox
|
||||
enableUnifiedMailbox: false,
|
||||
@@ -395,6 +414,11 @@ const DEFAULT_SETTINGS = {
|
||||
sidebarApps: [] as SidebarApp[],
|
||||
keepAppsLoaded: false,
|
||||
|
||||
// Onboarding
|
||||
onboardingCompleted: false,
|
||||
tourCompleted: false,
|
||||
showOnboardingOnNewDevices: false,
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
debugCategories: {
|
||||
@@ -470,10 +494,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
plainTextMode: state.plainTextMode,
|
||||
subAddressDelimiter: state.subAddressDelimiter,
|
||||
sendDelaySeconds: state.sendDelaySeconds,
|
||||
sendDelaySeconds: state.sendDelaySeconds,
|
||||
signaturePosition: state.signaturePosition,
|
||||
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
||||
sessionTimeout: state.sessionTimeout,
|
||||
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
||||
emailNotificationSound: state.emailNotificationSound,
|
||||
notificationSoundChoice: state.notificationSoundChoice,
|
||||
protocolOpenMode: state.protocolOpenMode,
|
||||
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
|
||||
calendarNotificationSound: state.calendarNotificationSound,
|
||||
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
|
||||
@@ -489,6 +517,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
toolbarPosition: state.toolbarPosition,
|
||||
hideAccountSwitcher: state.hideAccountSwitcher,
|
||||
showRailAccountList: state.showRailAccountList,
|
||||
proInterface: state.proInterface,
|
||||
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
||||
senderFavicons: state.senderFavicons,
|
||||
showAvatarsInJunk: state.showAvatarsInJunk,
|
||||
@@ -501,6 +530,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled,
|
||||
sidebarApps: state.sidebarApps,
|
||||
keepAppsLoaded: state.keepAppsLoaded,
|
||||
onboardingCompleted: state.onboardingCompleted,
|
||||
tourCompleted: state.tourCompleted,
|
||||
showOnboardingOnNewDevices: state.showOnboardingOnNewDevices,
|
||||
debugMode: state.debugMode,
|
||||
debugCategories: state.debugCategories,
|
||||
settingsSyncDisabled: state.settingsSyncDisabled,
|
||||
@@ -520,6 +552,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof settings.protocolOpenMode !== 'string' && typeof settings.protocolMailtoOpenMode === 'string') {
|
||||
settings.protocolOpenMode = settings.protocolMailtoOpenMode;
|
||||
}
|
||||
|
||||
// Apply settings
|
||||
Object.keys(settings).forEach((key) => {
|
||||
if (key in DEFAULT_SETTINGS) {
|
||||
@@ -698,7 +734,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}),
|
||||
{
|
||||
name: 'settings-storage',
|
||||
version: 2,
|
||||
version: 3,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted as Record<string, unknown>;
|
||||
if (version < 2 && state.listDensity) {
|
||||
@@ -708,6 +744,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) {
|
||||
state.sendDelaySeconds = 0;
|
||||
}
|
||||
if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') {
|
||||
state.protocolOpenMode = state.protocolMailtoOpenMode;
|
||||
}
|
||||
delete state.protocolMailtoOpenMode;
|
||||
return state as unknown as SettingsState;
|
||||
},
|
||||
onRehydrateStorage: () => {
|
||||
@@ -812,6 +852,13 @@ if (typeof window !== 'undefined') {
|
||||
if (res.status === 404) {
|
||||
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (res.status === 403) {
|
||||
// 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.
|
||||
syncWarn('Settings sync rejected (identity mismatch), disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (res.status >= 500 && retries > 0) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...');
|
||||
|
||||
+7
-152
@@ -16,116 +16,13 @@ import {
|
||||
extractCertificateInfo,
|
||||
} from '@/lib/smime/certificate-utils';
|
||||
|
||||
const REMEMBERED_UNLOCKS_STORAGE_KEY = 'smime-unlocked-session';
|
||||
|
||||
type RememberedUnlocks = Record<string, string>;
|
||||
|
||||
function readRememberedUnlocks(): RememberedUnlocks {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(REMEMBERED_UNLOCKS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rememberedUnlocks: RememberedUnlocks = {};
|
||||
for (const [keyId, passphrase] of Object.entries(parsed)) {
|
||||
if (typeof passphrase === 'string') {
|
||||
rememberedUnlocks[keyId] = passphrase;
|
||||
}
|
||||
}
|
||||
|
||||
return rememberedUnlocks;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRememberedUnlocks(rememberedUnlocks: RememberedUnlocks): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Object.keys(rememberedUnlocks).length === 0) {
|
||||
window.sessionStorage.removeItem(REMEMBERED_UNLOCKS_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_UNLOCKS_STORAGE_KEY,
|
||||
JSON.stringify(rememberedUnlocks),
|
||||
);
|
||||
} catch {
|
||||
// Ignore unavailable or blocked session storage.
|
||||
}
|
||||
}
|
||||
|
||||
function rememberUnlockedKey(keyId: string, passphrase: string): void {
|
||||
const rememberedUnlocks = readRememberedUnlocks();
|
||||
rememberedUnlocks[keyId] = passphrase;
|
||||
writeRememberedUnlocks(rememberedUnlocks);
|
||||
}
|
||||
|
||||
function forgetUnlockedKey(keyId: string): void {
|
||||
const rememberedUnlocks = readRememberedUnlocks();
|
||||
if (!(keyId in rememberedUnlocks)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete rememberedUnlocks[keyId];
|
||||
writeRememberedUnlocks(rememberedUnlocks);
|
||||
}
|
||||
|
||||
function clearRememberedUnlocks(): void {
|
||||
writeRememberedUnlocks({});
|
||||
}
|
||||
|
||||
async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
||||
unlockedKeys: Map<string, CryptoKey>;
|
||||
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
||||
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
|
||||
}> {
|
||||
const rememberedUnlocks = readRememberedUnlocks();
|
||||
const unlockedKeys = new Map<string, CryptoKey>();
|
||||
const unlockedDecryptionKeys = new Map<string, CryptoKey>();
|
||||
const unlockedLegacyDecryptionKeys = new Map<string, CryptoKey>();
|
||||
let removedStaleEntries = false;
|
||||
|
||||
for (const record of keyRecords) {
|
||||
const passphrase = rememberedUnlocks[record.id];
|
||||
if (!passphrase) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase);
|
||||
unlockedKeys.set(record.id, signingKey);
|
||||
if (decryptionKey) {
|
||||
unlockedDecryptionKeys.set(record.id, decryptionKey);
|
||||
}
|
||||
if (legacyDecryptionKey) {
|
||||
unlockedLegacyDecryptionKeys.set(record.id, legacyDecryptionKey);
|
||||
}
|
||||
} catch {
|
||||
delete rememberedUnlocks[record.id];
|
||||
removedStaleEntries = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedStaleEntries) {
|
||||
writeRememberedUnlocks(rememberedUnlocks);
|
||||
}
|
||||
|
||||
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
|
||||
// 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.
|
||||
const LEGACY_REMEMBERED_UNLOCKS_KEY = 'smime-unlocked-session';
|
||||
if (typeof window !== 'undefined') {
|
||||
try { window.sessionStorage.removeItem(LEGACY_REMEMBERED_UNLOCKS_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
interface SmimePersistedState {
|
||||
@@ -135,7 +32,6 @@ interface SmimePersistedState {
|
||||
defaultSignIdentity: Record<string, boolean>;
|
||||
defaultEncrypt: boolean;
|
||||
}>;
|
||||
rememberUnlockedKeys: boolean;
|
||||
autoImportSignerCerts: boolean;
|
||||
}
|
||||
|
||||
@@ -172,7 +68,6 @@ interface SmimeStore extends SmimePersistedState {
|
||||
getRecipientCerts: (emails: string[]) => { found: SmimePublicCert[]; missing: string[] };
|
||||
setSignDefault: (identityId: string, value: boolean) => void;
|
||||
setEncryptDefault: (value: boolean) => void;
|
||||
setRememberUnlockedKeys: (value: boolean) => void;
|
||||
setAutoImportSignerCerts: (value: boolean) => void;
|
||||
isKeyUnlocked: (id: string) => boolean;
|
||||
getUnlockedKey: (id: string) => CryptoKey | undefined;
|
||||
@@ -184,7 +79,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
(set, get) => ({
|
||||
// Persisted preferences
|
||||
accountPreferences: {},
|
||||
rememberUnlockedKeys: false,
|
||||
autoImportSignerCerts: true,
|
||||
|
||||
// Runtime state
|
||||
@@ -226,28 +120,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
listPublicCerts(acctId ?? undefined),
|
||||
]);
|
||||
|
||||
if (get().rememberUnlockedKeys) {
|
||||
const restoredKeys = await restoreRememberedKeys(keyRecords);
|
||||
set((state) => ({
|
||||
keyRecords,
|
||||
publicCerts,
|
||||
unlockedKeys: new Map([
|
||||
...state.unlockedKeys,
|
||||
...restoredKeys.unlockedKeys,
|
||||
]),
|
||||
unlockedDecryptionKeys: new Map([
|
||||
...state.unlockedDecryptionKeys,
|
||||
...restoredKeys.unlockedDecryptionKeys,
|
||||
]),
|
||||
unlockedLegacyDecryptionKeys: new Map([
|
||||
...state.unlockedLegacyDecryptionKeys,
|
||||
...restoredKeys.unlockedLegacyDecryptionKeys,
|
||||
]),
|
||||
isLoading: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
set({ keyRecords, publicCerts, isLoading: false });
|
||||
} catch (err) {
|
||||
set({
|
||||
@@ -338,7 +210,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
|
||||
removeKeyRecord: async (id) => {
|
||||
await deleteKeyRecordDB(id);
|
||||
forgetUnlockedKey(id);
|
||||
set((state) => {
|
||||
const unlockedKeys = new Map(state.unlockedKeys);
|
||||
unlockedKeys.delete(id);
|
||||
@@ -379,9 +250,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
if (!record) throw new Error('Key record not found');
|
||||
|
||||
const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase);
|
||||
if (get().rememberUnlockedKeys) {
|
||||
rememberUnlockedKey(id, passphrase);
|
||||
}
|
||||
set((state) => {
|
||||
const unlockedKeys = new Map(state.unlockedKeys);
|
||||
unlockedKeys.set(id, signingKey);
|
||||
@@ -398,7 +266,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
},
|
||||
|
||||
lockKey: (id) => {
|
||||
forgetUnlockedKey(id);
|
||||
set((state) => {
|
||||
const unlockedKeys = new Map(state.unlockedKeys);
|
||||
unlockedKeys.delete(id);
|
||||
@@ -411,7 +278,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
},
|
||||
|
||||
lockAllKeys: () => {
|
||||
clearRememberedUnlocks();
|
||||
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map() });
|
||||
},
|
||||
|
||||
@@ -474,14 +340,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setRememberUnlockedKeys: (value) => {
|
||||
set({ rememberUnlockedKeys: value });
|
||||
if (!value) {
|
||||
clearRememberedUnlocks();
|
||||
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map() });
|
||||
}
|
||||
},
|
||||
|
||||
setAutoImportSignerCerts: (value) => {
|
||||
set({ autoImportSignerCerts: value });
|
||||
},
|
||||
@@ -491,7 +349,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
getUnlockedKey: (id) => get().unlockedKeys.get(id),
|
||||
|
||||
clearState: () => {
|
||||
clearRememberedUnlocks();
|
||||
set({
|
||||
keyRecords: [],
|
||||
publicCerts: [],
|
||||
@@ -513,7 +370,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
name: 'smime-preferences',
|
||||
partialize: (state): SmimePersistedState => ({
|
||||
accountPreferences: state.accountPreferences,
|
||||
rememberUnlockedKeys: state.rememberUnlockedKeys,
|
||||
autoImportSignerCerts: state.autoImportSignerCerts,
|
||||
}),
|
||||
merge: (persisted, current) => {
|
||||
@@ -522,7 +378,6 @@ export const useSmimeStore = create<SmimeStore>()(
|
||||
...current,
|
||||
// Migrate legacy flat preferences into accountPreferences
|
||||
accountPreferences: p?.accountPreferences ?? {},
|
||||
rememberUnlockedKeys: p?.rememberUnlockedKeys ?? false,
|
||||
autoImportSignerCerts: p?.autoImportSignerCerts ?? true,
|
||||
};
|
||||
},
|
||||
|
||||
+25
-6
@@ -11,6 +11,10 @@ const SIDEBAR_DEFAULT = 256;
|
||||
const EMAIL_LIST_MIN = 240;
|
||||
const EMAIL_LIST_MAX = 600;
|
||||
const EMAIL_LIST_DEFAULT = 384;
|
||||
// Email list height (in pixels) for horizontal "Reading Pane at Bottom" layout
|
||||
const EMAIL_LIST_HEIGHT_MIN = 160;
|
||||
const EMAIL_LIST_HEIGHT_MAX = 800;
|
||||
const EMAIL_LIST_HEIGHT_DEFAULT = 320;
|
||||
|
||||
interface UIState {
|
||||
// Mobile view state
|
||||
@@ -28,6 +32,7 @@ interface UIState {
|
||||
// Resizable column widths (desktop only)
|
||||
sidebarWidth: number;
|
||||
emailListWidth: number;
|
||||
emailListHeight: number;
|
||||
|
||||
// Sidebar collapsed state (desktop)
|
||||
sidebarCollapsed: boolean;
|
||||
@@ -40,8 +45,10 @@ interface UIState {
|
||||
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
setEmailListWidth: (width: number) => void;
|
||||
setEmailListHeight: (height: number) => void;
|
||||
resetSidebarWidth: () => void;
|
||||
resetEmailListWidth: () => void;
|
||||
resetEmailListHeight: () => void;
|
||||
persistColumnWidths: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
toggleSidebarCollapsed: () => void;
|
||||
@@ -64,6 +71,7 @@ export const useUIStore = create<UIState>((set, get) => ({
|
||||
isDesktop: true,
|
||||
sidebarWidth: SIDEBAR_DEFAULT,
|
||||
emailListWidth: EMAIL_LIST_DEFAULT,
|
||||
emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT,
|
||||
sidebarCollapsed: false,
|
||||
|
||||
// Actions
|
||||
@@ -84,26 +92,37 @@ export const useUIStore = create<UIState>((set, get) => ({
|
||||
setEmailListWidth: (width) =>
|
||||
set({ emailListWidth: Math.min(EMAIL_LIST_MAX, Math.max(EMAIL_LIST_MIN, width)) }),
|
||||
|
||||
setEmailListHeight: (height) =>
|
||||
set({ emailListHeight: Math.min(EMAIL_LIST_HEIGHT_MAX, Math.max(EMAIL_LIST_HEIGHT_MIN, height)) }),
|
||||
|
||||
resetSidebarWidth: () => {
|
||||
set({ sidebarWidth: SIDEBAR_DEFAULT });
|
||||
const { emailListWidth } = get();
|
||||
const { emailListWidth, emailListHeight } = get();
|
||||
try {
|
||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth }));
|
||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth, emailListHeight }));
|
||||
} catch { /* localStorage may be unavailable */ }
|
||||
},
|
||||
|
||||
resetEmailListWidth: () => {
|
||||
set({ emailListWidth: EMAIL_LIST_DEFAULT });
|
||||
const { sidebarWidth } = get();
|
||||
const { sidebarWidth, emailListHeight } = get();
|
||||
try {
|
||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT }));
|
||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT, emailListHeight }));
|
||||
} catch { /* localStorage may be unavailable */ }
|
||||
},
|
||||
|
||||
resetEmailListHeight: () => {
|
||||
set({ emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT });
|
||||
const { sidebarWidth, emailListWidth } = get();
|
||||
try {
|
||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT }));
|
||||
} catch { /* localStorage may be unavailable */ }
|
||||
},
|
||||
|
||||
persistColumnWidths: () => {
|
||||
const { sidebarWidth, emailListWidth } = get();
|
||||
const { sidebarWidth, emailListWidth, emailListHeight } = get();
|
||||
try {
|
||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth }));
|
||||
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight }));
|
||||
} catch { /* localStorage may be unavailable */ }
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user