Merge branch 'main' into feature/scheduled-send

This commit is contained in:
Lucas Gaitzsch
2026-05-20 08:17:46 +02:00
244 changed files with 20839 additions and 3479 deletions
+106 -37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 */
}
}
+526
View File
@@ -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];
}
},
},
),
);
+49 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 */ }
},