Merge remote-tracking branch 'origin/main' into feature/scheduled-send

# Conflicts:
#	app/[locale]/page.tsx
#	components/email/email-composer.tsx
#	components/email/email-viewer.tsx
This commit is contained in:
Lucas Gaitzsch
2026-05-07 18:01:41 +02:00
120 changed files with 7033 additions and 4882 deletions
+8 -8
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { generateAccountId, generateAvatarColor, MAX_ACCOUNTS } from '@/lib/account-utils';
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
export interface AccountEntry {
/** Unique key: `${username}@${serverHostname}` */
@@ -13,7 +13,7 @@ export interface AccountEntry {
username: string;
/** Authentication mode */
authMode: 'basic' | 'oauth';
/** Cookie slot index (04) for session/token cookies */
/** Cookie slot index for session/token cookies (0 ≤ slot < MAX_ACCOUNT_SLOTS) */
cookieSlot: number;
/** Whether "Remember Me" was checked (basic auth only) */
rememberMe: boolean;
@@ -80,8 +80,9 @@ export const useAccountStore = create<AccountState>()(
return id;
}
if (state.accounts.length >= MAX_ACCOUNTS) {
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
const max = getMaxAccounts();
if (state.accounts.length >= max) {
throw new Error(`Maximum of ${max} accounts reached`);
}
const cookieSlot = state.getNextCookieSlot();
@@ -178,10 +179,9 @@ export const useAccountStore = create<AccountState>()(
getNextCookieSlot: () => {
const used = new Set(get().accounts.map((a) => a.cookieSlot));
for (let i = 0; i < MAX_ACCOUNTS; i++) {
if (!used.has(i)) return i;
}
return 0; // fallback, shouldn't happen if max is enforced
let i = 0;
while (used.has(i)) i++;
return i;
},
hasAccount: (username, serverUrl) => {
+41
View File
@@ -0,0 +1,41 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const ADMIN_TABS = [
'dashboard',
'settings',
'branding',
'auth',
'policy',
'plugins',
'themes',
'marketplace',
'version',
'telemetry',
'logs',
] as const;
export type AdminTabId = typeof ADMIN_TABS[number];
export function isAdminTab(value: string | null | undefined): value is AdminTabId {
return typeof value === 'string' && (ADMIN_TABS as readonly string[]).includes(value);
}
interface AdminTabState {
activeTab: AdminTabId;
setActiveTab: (tab: AdminTabId) => void;
}
// Tab state lives in client memory + localStorage. Sidebar clicks update
// state (no URL navigation) so React can commit the transition immediately,
// avoiding the dev-mode "Rendering…" hang we saw when each tab was its own
// route or distinguished by ?tab= search param.
export const useAdminTabStore = create<AdminTabState>()(
persist(
(set) => ({
activeTab: 'dashboard',
setActiveTab: (tab) => set({ activeTab: tab }),
}),
{ name: 'admin_active_tab' },
),
);
+15 -6
View File
@@ -37,7 +37,7 @@ interface AuthState {
isDemoMode: boolean;
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string, serverId?: string) => Promise<boolean>;
loginWithServerSso: (code: string, state: string) => Promise<boolean>;
loginDemo: () => Promise<boolean>;
refreshAccessToken: () => Promise<string | null>;
@@ -408,6 +408,9 @@ export const useAuthStore = create<AuthState>()(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
// Note: server_id isn't passed here - the route looks up the
// server entry by serverUrl, so per-server OAuth still applies
// for password+TOTP logins through the dropdown.
});
if (tokenRes.ok) {
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
@@ -597,14 +600,14 @@ export const useAuthStore = create<AuthState>()(
}
},
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri, serverId) => {
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
try {
// Determine slot for this account (use slot from sessionStorage if re-adding).
// Note: `parseInt(getItem(...) || '0')` collapses "no value set" and
// "value is 0" into the same case, so the fallback to getNextCookieSlot()
// never fired for the common "+ Add Account" path every OAuth account
// never fired for the common "+ Add Account" path - every OAuth account
// ended up on slot 0 and overwrote earlier accounts' refresh-token cookies.
// Distinguishing rawSlot === null from a parsed 0 fixes that. The page
// also writes oauth_cookie_slot before redirecting to the IdP.
@@ -620,7 +623,13 @@ export const useAuthStore = create<AuthState>()(
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri, slot }),
body: JSON.stringify({
code,
code_verifier: codeVerifier,
redirect_uri: redirectUri,
slot,
...(serverId ? { server_id: serverId } : {}),
}),
});
if (!tokenRes.ok) {
@@ -800,7 +809,7 @@ export const useAuthStore = create<AuthState>()(
isDefault: accountStore.accounts.length === 0,
});
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete.
// Force the stored cookieSlot to match see loginWithOAuth above for the
// Force the stored cookieSlot to match - see loginWithOAuth above for the
// re-add and concurrent-tab cases this guards against.
accountStore.updateAccount(accountId, { cookieSlot: slot });
accountStore.setActiveAccount(accountId);
@@ -1232,7 +1241,7 @@ export const useAuthStore = create<AuthState>()(
for (const account of accounts) {
if (clients.has(account.id)) continue; // Already connected
// Basic auth without rememberMe leaves nothing to restore the
// Basic auth without rememberMe leaves nothing to restore - the
// user logged in without persisting credentials. Evict silently
// so the login screen is shown without flagging a fake error.
if (account.authMode === 'basic' && !account.rememberMe) {
+47 -6
View File
@@ -98,6 +98,9 @@ export const usePluginStore = create<PluginStoreState>()(
adminApproved: false, // Requires admin approval before it can be enabled
settings: existing?.settings ?? {},
settingsSchema: manifest.settingsSchema,
...(manifest.httpOrigins && manifest.httpOrigins.length > 0
? { httpOrigins: manifest.httpOrigins }
: {}),
};
// Save code to IndexedDB
@@ -303,6 +306,15 @@ interface ServerPluginInfo {
permissions: string[];
entrypoint: string;
forceEnabled: boolean;
/** Content hash of the bundle - changes whenever code changes, even if the version doesn't */
bundleHash?: string;
updatedAt?: string;
/** True when the plugin was loaded from the server's PLUGIN_DEV_DIR */
dev?: boolean;
/** Allowlist of origins this plugin may target via api.http.fetch(). */
httpOrigins?: string[];
/** Per-user settings schema, captured from the manifest server-side. */
settingsSchema?: InstalledPlugin['settingsSchema'];
}
const SERVER_MANAGED_KEY = 'server-managed-plugin-ids';
@@ -382,7 +394,7 @@ async function syncServerPlugins(
if (!local) {
// New server plugin - download and install
const code = await downloadPluginBundle(sp.id);
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
if (!code) continue;
await pluginStorage.saveCode(sp.id, code);
@@ -402,6 +414,11 @@ async function syncServerPlugins(
forceEnabled: sp.forceEnabled,
adminApproved: true, // Server-managed plugins are always approved
settings: {},
settingsSchema: sp.settingsSchema,
bundleHash: sp.bundleHash,
...(sp.httpOrigins && sp.httpOrigins.length > 0
? { httpOrigins: sp.httpOrigins }
: {}),
};
set(state => {
@@ -410,9 +427,15 @@ async function syncServerPlugins(
}
return { plugins: [...state.plugins, plugin] };
});
} else if (local.version !== sp.version) {
// Version changed - re-download bundle
const code = await downloadPluginBundle(sp.id);
} else if (
local.version !== sp.version ||
// bundleHash mismatch covers re-uploads of the same version with new
// code. Falsy local hash (older installs that never carried one) also
// forces a refresh so we capture the hash on the next sync.
(sp.bundleHash && local.bundleHash !== sp.bundleHash)
) {
// Version or content changed - re-download bundle
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
if (!code) continue;
await pluginStorage.saveCode(sp.id, code);
@@ -430,6 +453,9 @@ async function syncServerPlugins(
entrypoint: sp.entrypoint,
managed: true,
forceEnabled: sp.forceEnabled,
bundleHash: sp.bundleHash,
httpOrigins: sp.httpOrigins,
settingsSchema: sp.settingsSchema,
}
: p
),
@@ -442,10 +468,22 @@ async function syncServerPlugins(
...p,
managed: true,
forceEnabled: sp.forceEnabled,
settingsSchema: sp.settingsSchema,
}
: p
),
}));
} else if (
JSON.stringify(local.settingsSchema ?? null) !== JSON.stringify(sp.settingsSchema ?? null)
) {
// Schema drift: the bundle is current but the persisted plugin record
// pre-dates the server passing settingsSchema through, so the per-user
// settings UI was rendering empty. Patch the schema in place.
set(state => ({
plugins: state.plugins.map(p =>
p.id === sp.id ? { ...p, settingsSchema: sp.settingsSchema } : p
),
}));
} else if (sp.forceEnabled && !local.enabled) {
// Force-enable if the server says so but client has it disabled
set(state => ({
@@ -483,9 +521,12 @@ async function syncServerPlugins(
}
}
async function downloadPluginBundle(pluginId: string): Promise<string | null> {
async function downloadPluginBundle(pluginId: string, bundleHash?: string): Promise<string | null> {
try {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle`);
// Append the hash as a query string so any intermediary HTTP cache
// (browser, service worker, CDN) treats each version as a distinct URL.
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();
} catch {
-2
View File
@@ -192,7 +192,6 @@ interface SettingsState {
// Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
// Experimental
senderFavicons: boolean;
showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder
@@ -346,7 +345,6 @@ const DEFAULT_SETTINGS = {
// Email Display
disableThreading: false,
// Experimental
senderFavicons: true,
showAvatarsInJunk: false,
+1 -1
View File
@@ -41,7 +41,7 @@ export const useUpdateStore = create<UpdateState>()((set, get) => ({
lastFetchedAt: Date.now(),
});
} catch {
// Silent banner just won't appear, no need to disrupt the UI.
// Silent - banner just won't appear, no need to disrupt the UI.
} finally {
set({ loading: false });
inFlight = null;