From 433a63bf1a569478cbb781cbf16069b5d28a79f2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 18:42:12 +0200 Subject: [PATCH 01/44] fix: normalize malformed contact photo data URIs #307 --- components/contacts/contact-form.tsx | 5 ++- stores/__tests__/contact-store.test.ts | 42 +++++++++++++++++++++++++- stores/contact-store.ts | 19 +++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 5fa6c363..c99491fa 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -6,6 +6,7 @@ import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar } from "@/components/ui/avatar"; +import { normalizeContactPhotoUri } from "@/stores/contact-store"; import { cn } from "@/lib/utils"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; @@ -341,7 +342,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const initialPhotoEntry = useMemo(() => { if (!contact?.media) return null; for (const [key, m] of Object.entries(contact.media)) { - if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType }; + if (m.kind === "photo" && m.uri) { + return { key, uri: normalizeContactPhotoUri(m.uri, m.mediaType), mediaType: m.mediaType }; + } } return null; }, [contact]); diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index 093e40d3..dd9dbec7 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { useContactStore } from '../contact-store'; +import { useContactStore, getContactPhotoUri, normalizeContactPhotoUri } from '../contact-store'; import type { ContactCard } from '@/lib/jmap/types'; vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' }); @@ -496,6 +496,46 @@ describe('contact-store', () => { }); }); + describe('normalizeContactPhotoUri', () => { + it('rewrites malformed data:base64,... URIs using the media mediaType', () => { + expect(normalizeContactPhotoUri('data:base64,AAAA', 'image/png')) + .toBe('data:image/png;base64,AAAA'); + }); + + it('rewrites data:;base64,... URIs using the media mediaType', () => { + expect(normalizeContactPhotoUri('data:;base64,AAAA', 'image/gif')) + .toBe('data:image/gif;base64,AAAA'); + }); + + it('defaults to image/jpeg when no mediaType is available', () => { + expect(normalizeContactPhotoUri('data:base64,AAAA')) + .toBe('data:image/jpeg;base64,AAAA'); + }); + + it('leaves well-formed data URIs unchanged', () => { + const good = 'data:image/png;base64,AAAA'; + expect(normalizeContactPhotoUri(good)).toBe(good); + }); + + it('leaves http(s) URIs unchanged', () => { + const url = 'https://example.com/photo.jpg'; + expect(normalizeContactPhotoUri(url)).toBe(url); + }); + }); + + describe('getContactPhotoUri', () => { + it('returns a normalized data URI for malformed Stalwart photos (#307)', () => { + const contact = makeContact({ + media: { m0: { kind: 'photo', uri: 'data:base64,AAAA', mediaType: 'image/png' } }, + }); + expect(getContactPhotoUri(contact)).toBe('data:image/png;base64,AAAA'); + }); + + it('returns undefined when no photo media is present', () => { + expect(getContactPhotoUri(makeContact())).toBeUndefined(); + }); + }); + describe('persistence/partialize', () => { it('should persist contacts when supportsSync is false', () => { const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record) => Record } } }).persist.getOptions(); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index a376ed7a..df33ddd9 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -37,10 +37,27 @@ export function getContactPrimaryEmail(contact: ContactCard): string { return Object.values(contact.emails)[0]?.address || ''; } +// Some JMAP servers (notably Stalwart, see issue #307) emit photo data URIs +// without a mediatype, like `data:base64,...` or `data:;base64,...`. Per +// RFC 2397 the missing/empty mediatype defaults to `text/plain`, so browsers +// won't render the bytes as an image. Rewrite to include a mediatype. +export function normalizeContactPhotoUri(uri: string, mediaType?: string): string { + const mime = mediaType && mediaType.includes('/') ? mediaType : 'image/jpeg'; + if (uri.startsWith('data:base64,')) { + return `data:${mime};base64,${uri.slice('data:base64,'.length)}`; + } + if (uri.startsWith('data:;base64,')) { + return `data:${mime};base64,${uri.slice('data:;base64,'.length)}`; + } + return uri; +} + export function getContactPhotoUri(contact: ContactCard): string | undefined { if (!contact.media) return undefined; for (const media of Object.values(contact.media)) { - if (media.kind === 'photo' && media.uri) return media.uri; + if (media.kind === 'photo' && media.uri) { + return normalizeContactPhotoUri(media.uri, media.mediaType); + } } return undefined; } From 1c44f59ba175dd96257590efa8b74a3430c35f75 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 19:01:46 +0200 Subject: [PATCH 02/44] feat: allow setup wizard over plain HTTP with dismissable warning gate --- app/api/setup/token/route.ts | 2 +- app/setup/page.tsx | 69 +++++++++++++++++------------------- lib/setup/session.ts | 18 ++++++++-- 3 files changed, 50 insertions(+), 39 deletions(-) diff --git a/app/api/setup/token/route.ts b/app/api/setup/token/route.ts index 4b22b794..a2024ba9 100644 --- a/app/api/setup/token/route.ts +++ b/app/api/setup/token/route.ts @@ -37,7 +37,7 @@ export async function POST(request: NextRequest) { } const response = NextResponse.json({ ok: true }); - const attrs = buildSessionCookieAttributes(); + const attrs = buildSessionCookieAttributes(request); response.cookies.set(attrs.name, submitted, { httpOnly: attrs.httpOnly, sameSite: attrs.sameSite, diff --git a/app/setup/page.tsx b/app/setup/page.tsx index d40f0534..09859fd3 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -101,20 +101,13 @@ export default function SetupWizardPage() { const [config, setConfig] = useState(EMPTY_CONFIG); const [stepIndex, setStepIndex] = useState(0); const [completed, setCompleted] = useState(false); - // Detect synchronously on first client render so we don't flash the loading - // screen before the warning appears. The session cookie is set with the - // Secure flag in production, which browsers silently drop over plain HTTP - - // every subsequent step call then 401s with "Wizard session required". + // Detect synchronously on first client render so the cleartext-credentials + // warning is in the first paint instead of popping in after hydration. const [insecureContext] = useState(detectInsecureContext); + const [insecureAcknowledged, setInsecureAcknowledged] = useState(false); // ─── Initial status load ──────────────────────────────────────────────── useEffect(() => { - // Skip the status fetch entirely when we're going to render the HTTPS - // notice - the wizard cookie can't survive an HTTP origin anyway. - if (insecureContext) { - setBootstrapping(false); - return; - } let cancelled = false; (async () => { try { @@ -152,7 +145,7 @@ export default function SetupWizardPage() { return () => { cancelled = true; }; - }, [router, insecureContext]); + }, [router]); // ─── Token submit (welcome step) ──────────────────────────────────────── async function submitToken(token: string) { @@ -184,8 +177,8 @@ export default function SetupWizardPage() { } // ─── Render shell ─────────────────────────────────────────────────────── - if (insecureContext) { - return ; + if (insecureContext && !insecureAcknowledged) { + return setInsecureAcknowledged(true)} />; } if (bootstrapping) { @@ -362,7 +355,7 @@ function CompletedScreen() { ); } -function InsecureContextScreen() { +function InsecureContextScreen({ onContinue }: { onContinue: () => void }) { const httpsUrl = typeof window !== 'undefined' ? `https://${window.location.host}${window.location.pathname}${window.location.search}` @@ -373,29 +366,29 @@ function InsecureContextScreen() {
-

HTTPS required for setup

-

- The setup wizard signs you in with a Secure cookie, - which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every - step to fail with Wizard session required. +

You're running setup over plain HTTP

+

+ The setup token and admin password you enter here will travel in cleartext. + Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it.

-
-

To continue, do one of the following:

-
    -
  • Reach this page over HTTPS (terminate TLS on the container or a reverse proxy in front of it).
  • -
  • If you already have a reverse proxy, make sure it forwards to the webmail and forwards the - X-Forwarded-Proto header.
  • -
-
- {httpsUrl && ( - + {httpsUrl && ( + + Try HTTPS + + )} + + ); } @@ -1786,9 +1779,13 @@ function detectInsecureContext(): boolean { if (typeof window === 'undefined') return false; if (window.location.protocol !== 'http:') return false; // Browsers treat localhost/loopback as "potentially trustworthy" and accept - // Secure cookies even without TLS, so the wizard still works there. + // Secure cookies even without TLS, so the wizard still works there. In dev + // we still want to render the warning so we can preview it without spinning + // up a non-loopback host. const host = window.location.hostname; - if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') { + const isLoopback = + host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; + if (isLoopback && process.env.NODE_ENV !== 'development') { return false; } return true; diff --git a/lib/setup/session.ts b/lib/setup/session.ts index e4922e4e..a3ee7368 100644 --- a/lib/setup/session.ts +++ b/lib/setup/session.ts @@ -1,4 +1,5 @@ import { cookies } from 'next/headers'; +import type { NextRequest } from 'next/server'; import { verifySetupToken } from './token'; export const SETUP_COOKIE = 'bulwark_setup_token'; @@ -21,13 +22,26 @@ export async function authenticateWizardRequest(): Promise { return verifySetupToken(token); } -export function buildSessionCookieAttributes() { +export function buildSessionCookieAttributes(request?: NextRequest) { + // Match Secure to the actual request protocol. Browsers drop Secure cookies + // on plain HTTP, so unconditionally setting Secure in production breaks + // setup over HTTP — the operator gets "Wizard session required" on every + // step. The wizard surfaces a cleartext-credentials warning in the UI when + // HTTPS isn't in use. return { name: SETUP_COOKIE, httpOnly: true, sameSite: 'lax' as const, - secure: process.env.NODE_ENV === 'production', + secure: request ? isHttpsRequest(request) : process.env.NODE_ENV === 'production', path: '/', maxAge: COOKIE_MAX_AGE, }; } + +function isHttpsRequest(request: NextRequest): boolean { + const forwarded = request.headers.get('x-forwarded-proto'); + if (forwarded) { + return forwarded.split(',')[0]!.trim().toLowerCase() === 'https'; + } + return request.nextUrl.protocol === 'https:'; +} From 5023d312020501c902ad3aaf22fc27d61fb9ee66 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 19:05:11 +0200 Subject: [PATCH 03/44] fix: defer setup wizard HTTP detection to avoid hydration mismatch --- app/setup/page.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 09859fd3..761cdf54 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -101,10 +101,14 @@ export default function SetupWizardPage() { const [config, setConfig] = useState(EMPTY_CONFIG); const [stepIndex, setStepIndex] = useState(0); const [completed, setCompleted] = useState(false); - // Detect synchronously on first client render so the cleartext-credentials - // warning is in the first paint instead of popping in after hydration. - const [insecureContext] = useState(detectInsecureContext); + // Resolved in a post-mount effect, not at render, so the server-rendered + // HTML (where window is absent) matches the client's first paint and + // doesn't trip a hydration mismatch. + const [insecureContext, setInsecureContext] = useState(false); const [insecureAcknowledged, setInsecureAcknowledged] = useState(false); + useEffect(() => { + setInsecureContext(detectInsecureContext()); + }, []); // ─── Initial status load ──────────────────────────────────────────────── useEffect(() => { From ba90ec1f7ad417e663427ddf3def1aca02d54362 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 19:11:46 +0200 Subject: [PATCH 04/44] feat: warn when setup JMAP URL points at a local-only host --- app/setup/page.tsx | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 761cdf54..64e9cad6 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -740,6 +740,21 @@ function ServerStep({ config, setConfig, onNext }: Pick )} + {isPrivateOrLocalHostUrl(config.jmapServerUrl) && ( +
+
+ +
+
+

+ This URL only resolves locally. +

+

+ Mail is fetched directly from the user's browser, so the JMAP URL must be reachable from anywhere users sign in - not just this machine or LAN. Use a public hostname (e.g. https://mail.example.com) in production. +

+
+
+ )} {probe && probe.url === config.jmapServerUrl && ( probe.status === 'jmap_detected' ? (
@@ -1779,6 +1794,41 @@ function isInsecureHttpUrl(url: string): boolean { return /^http:\/\//i.test(url.trim()); } +/** + * The JMAP URL is called directly from the user's browser. A URL that only + * resolves on the operator's machine or LAN (localhost, RFC1918, .local mDNS) + * works during setup but breaks for any real user. Surface a soft warning + * so the operator catches this before going live. + */ +function isPrivateOrLocalHostUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed) return false; + let host: string; + try { + host = new URL(trimmed).hostname.toLowerCase(); + } catch { + return false; + } + // Strip IPv6 brackets, if any. + if (host.startsWith('[') && host.endsWith(']')) { + host = host.slice(1, -1); + } + if (host === 'localhost' || host.endsWith('.localhost')) return true; + if (host.endsWith('.local')) return true; + if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true; + // IPv4 literal: only flag the well-known private/loopback/link-local ranges. + const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + if (a === 10) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + } + return false; +} + function detectInsecureContext(): boolean { if (typeof window === 'undefined') return false; if (window.location.protocol !== 'http:') return false; From d45c8ef511ac8f4bc64dbe0bb4654e77ba10e4dd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 19:22:44 +0200 Subject: [PATCH 05/44] feat: list and reorder logged-in accounts in settings #282 --- app/[locale]/settings/page.tsx | 3 +- components/settings/account-settings.tsx | 396 +++++++++++++++++++---- locales/de/common.json | 12 + locales/en/common.json | 12 + stores/account-store.ts | 18 ++ 5 files changed, 379 insertions(+), 62 deletions(-) diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index f910dc42..7f58d935 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -161,6 +161,7 @@ const tabSearchPaths: Record = { 'settings.account.email', 'settings.account.server', 'settings.account.storage', + 'settings.account.accounts', ], language: ['settings.appearance.language'], notifications: ['settings.notifications'], @@ -228,7 +229,7 @@ const tabSearchPaths: Record = { // Extra English keywords per tab so common search terms hit even when the // translation doesn't contain the literal word. const tabKeywords: Record = { - account: 'profile email password user signin signout', + account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account', language: 'locale region timezone date time format', notifications: 'sound alert push badge', appearance: 'theme dark light font size accent color animation density', diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index 3e70b9cd..a332d463 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -1,87 +1,361 @@ "use client"; +import { useState, useRef, useCallback } from 'react'; import { useTranslations } from 'next-intl'; +import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; -import { useAccountStore } from '@/stores/account-store'; +import { useAccountStore, type AccountEntry } from '@/stores/account-store'; import { SettingsSection, SettingItem } from './settings-section'; -import { formatFileSize } from '@/lib/utils'; +import { Avatar } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { useRouter } from '@/i18n/navigation'; +import { getMaxAccounts } from '@/lib/account-utils'; +import { formatFileSize, cn } from '@/lib/utils'; + +function hostnameOf(serverUrl: string): string { + try { return new URL(serverUrl).hostname; } catch { return serverUrl; } +} export function AccountSettings() { const t = useTranslations('settings.account'); - const { username, serverUrl, isDemoMode, primaryIdentity, authMode, activeAccountId } = useAuthStore(); + const router = useRouter(); + const { username, serverUrl, isDemoMode, primaryIdentity, authMode } = useAuthStore(); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const switchAccount = useAuthStore((s) => s.switchAccount); const { quota } = useEmailStore(); + const accounts = useAccountStore((s) => s.accounts); + const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); + const reorderAccounts = useAccountStore((s) => s.reorderAccounts); const account = useAccountStore((s) => activeAccountId ? s.getAccountById(activeAccountId) : undefined); + const [dragOverIndex, setDragOverIndex] = useState(null); + const draggedIndexRef = useRef(null); + const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0; const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined); const email = primaryIdentity?.email || account?.email || username; + const max = getMaxAccounts(); + + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { + draggedIndexRef.current = index; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(index)); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverIndex(index); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + setDragOverIndex(null); + const fromIndex = draggedIndexRef.current; + if (fromIndex === null || fromIndex === dropIndex) return; + const next = accounts.map((a) => a.id); + const [moved] = next.splice(fromIndex, 1); + next.splice(dropIndex, 0, moved); + reorderAccounts(next); + }, [accounts, reorderAccounts]); + + const handleDragEnd = useCallback(() => { + draggedIndexRef.current = null; + setDragOverIndex(null); + }, []); + + const moveAccount = useCallback((from: number, to: number) => { + if (to < 0 || to >= accounts.length || from === to) return; + const next = accounts.map((a) => a.id); + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + reorderAccounts(next); + }, [accounts, reorderAccounts]); + + const handleSwitch = useCallback((id: string) => { + if (id === activeAccountId) return; + void switchAccount(id); + }, [activeAccountId, switchAccount]); + + const handleAddAccount = useCallback(() => { + router.push(`/login?mode=add-account` as never); + }, [router]); return ( - - {/* Display Name */} - - {displayName || t('../../common.unknown')} - - - {/* Email Address */} - - {email || t('../../common.unknown')} - - - {/* Username / Login (show when it differs from email) */} - {username && username !== email && ( - - {username} +
+ + {/* Display Name */} + + {displayName || t('../../common.unknown')} - )} - {/* Authentication Method */} - - - {authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')} - - - - {/* Server */} - - - {serverUrl || t('../../common.unknown')} - - - - {/* Storage */} - {quota && quota.total > 0 && ( - -
- - {t('storage.percentage', { percent: quotaPercentage })} - -
-
-
-
+ {/* Email Address */} + + {email || t('../../common.unknown')} - )} - {/* Demo mode indicator */} - {isDemoMode && ( - - - - {t('demo_account')} + {/* Username / Login (show when it differs from email) */} + {username && username !== email && ( + + {username} + + )} + + {/* Authentication Method */} + + + {authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')} + + {/* Server */} + + + {serverUrl || t('../../common.unknown')} + + + + {/* Storage */} + {quota && quota.total > 0 && ( + +
+ + {t('storage.percentage', { percent: quotaPercentage })} + +
+
+
+
+ + )} + + {/* Demo mode indicator */} + {isDemoMode && ( + + + + {t('demo_account')} + + + )} + + + {/* Logged-in accounts list */} + {accounts.length > 0 && ( + +
+ {accounts.map((a, index) => ( + moveAccount(index, index - 1)} + onMoveDown={() => moveAccount(index, index + 1)} + onSwitch={() => handleSwitch(a.id)} + onSetDefault={() => setDefaultAccount(a.id)} + labels={{ + active: t('accounts.active'), + default: t('accounts.default_badge'), + setDefault: t('accounts.set_default'), + switchTo: t('accounts.switch_to'), + moveUp: t('accounts.move_up'), + moveDown: t('accounts.move_down'), + dragHandle: t('accounts.drag_handle'), + }} + /> + ))} + + {accounts.length < max && ( + + )} +
+
)} - +
+ ); +} + +interface AccountRowProps { + account: AccountEntry; + index: number; + isActive: boolean; + isFirst: boolean; + isLast: boolean; + isDragOver: boolean; + onDragStart: (e: React.DragEvent, index: number) => void; + onDragOver: (e: React.DragEvent, index: number) => void; + onDrop: (e: React.DragEvent, index: number) => void; + onDragEnd: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + onSwitch: () => void; + onSetDefault: () => void; + labels: { + active: string; + default: string; + setDefault: string; + switchTo: string; + moveUp: string; + moveDown: string; + dragHandle: string; + }; +} + +function AccountRow({ + account, + index, + isActive, + isFirst, + isLast, + isDragOver, + onDragStart, + onDragOver, + onDrop, + onDragEnd, + onMoveUp, + onMoveDown, + onSwitch, + onSetDefault, + labels, +}: AccountRowProps) { + return ( +
onDragStart(e, index)} + onDragOver={(e) => onDragOver(e, index)} + onDrop={(e) => onDrop(e, index)} + onDragEnd={onDragEnd} + className={cn( + 'flex items-center gap-3 p-3 border rounded-lg transition-colors', + isDragOver + ? 'border-primary bg-primary/5' + : isActive + ? 'border-border bg-accent/30' + : 'border-border hover:bg-muted/50' + )} + > +
+ +
+ +
+ + {isActive && ( +
+ +
+ )} +
+ + + +
+ {!account.isDefault && ( + + )} + + +
+
); } diff --git a/locales/de/common.json b/locales/de/common.json index 8a0fb9c7..4b0c168d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Letzte Synchronisierung", "value": "{time}" + }, + "accounts": { + "title": "Angemeldete Konten", + "description": "Ziehen Sie zum Sortieren, wie Konten im Kontomenü erscheinen", + "active": "Aktuell aktives Konto", + "default_badge": "Standardkonto", + "set_default": "Als Standard festlegen", + "switch_to": "Zu diesem Konto wechseln", + "move_up": "Nach oben", + "move_down": "Nach unten", + "drag_handle": "Zum Sortieren ziehen", + "add": "Konto hinzufügen" } }, "security": { diff --git a/locales/en/common.json b/locales/en/common.json index 9b33aac4..45c0232b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1202,6 +1202,18 @@ "last_sync": { "label": "Last Sync", "value": "{time}" + }, + "accounts": { + "title": "Logged-in accounts", + "description": "Drag to reorder how accounts appear in the account dropdown", + "active": "Currently active account", + "default_badge": "Default account", + "set_default": "Set as default", + "switch_to": "Switch to this account", + "move_up": "Move up", + "move_down": "Move down", + "drag_handle": "Drag to reorder", + "add": "Add account" } }, "security": { diff --git a/stores/account-store.ts b/stores/account-store.ts index 93f452ac..eb33ebe3 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -43,6 +43,7 @@ interface AccountState { setDefaultAccount: (accountId: string) => void; getDefaultAccount: () => AccountEntry | null; updateAccount: (accountId: string, updates: Partial) => void; + reorderAccounts: (orderedIds: string[]) => void; getActiveAccount: () => AccountEntry | null; getAccountById: (accountId: string) => AccountEntry | undefined; getNextCookieSlot: () => number; @@ -168,6 +169,23 @@ export const useAccountStore = create()( })); }, + reorderAccounts: (orderedIds) => { + set((s) => { + const byId = new Map(s.accounts.map((a) => [a.id, a])); + const reordered: AccountEntry[] = []; + for (const id of orderedIds) { + const a = byId.get(id); + if (a) { + reordered.push(a); + byId.delete(id); + } + } + // Append any accounts that weren't in the ordered list (defensive) + for (const a of byId.values()) reordered.push(a); + return { accounts: reordered }; + }); + }, + getActiveAccount: () => { const state = get(); return state.accounts.find((a) => a.id === state.activeAccountId) ?? null; From 628966d3b51968e45b10f833911c605238492c3c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 23:41:49 +0200 Subject: [PATCH 06/44] fix: split app into (main)/(sandbox) route groups so plugin iframe hydrates properly --- .../[locale]/auth/callback/page.tsx | 0 app/{ => (main)}/[locale]/calendar/page.tsx | 0 app/{ => (main)}/[locale]/contacts/page.tsx | 0 app/{ => (main)}/[locale]/error.tsx | 0 app/{ => (main)}/[locale]/files/page.tsx | 0 app/{ => (main)}/[locale]/layout.tsx | 0 app/{ => (main)}/[locale]/login/page.tsx | 0 app/{ => (main)}/[locale]/page.tsx | 0 app/{ => (main)}/[locale]/pro/page.tsx | 10 ++--- app/{ => (main)}/[locale]/settings/page.tsx | 0 .../admin/_tabs/_jmap-servers-section.tsx | 0 app/{ => (main)}/admin/_tabs/auth.tsx | 0 app/{ => (main)}/admin/_tabs/branding.tsx | 0 app/{ => (main)}/admin/_tabs/dashboard.tsx | 0 app/{ => (main)}/admin/_tabs/logs.tsx | 0 app/{ => (main)}/admin/_tabs/marketplace.tsx | 0 .../admin/_tabs/plugin-config-panel.tsx | 0 app/{ => (main)}/admin/_tabs/plugins.tsx | 0 app/{ => (main)}/admin/_tabs/policy.tsx | 0 app/{ => (main)}/admin/_tabs/settings.tsx | 0 app/{ => (main)}/admin/_tabs/telemetry.tsx | 0 app/{ => (main)}/admin/_tabs/themes.tsx | 0 app/{ => (main)}/admin/_tabs/version.tsx | 0 app/{ => (main)}/admin/auth/page.tsx | 0 app/{ => (main)}/admin/branding/page.tsx | 0 .../admin/change-password/page.tsx | 0 app/{ => (main)}/admin/layout.tsx | 0 app/{ => (main)}/admin/login/page.tsx | 0 app/{ => (main)}/admin/logs/page.tsx | 0 .../admin/marketplace/[slug]/page.tsx | 0 app/{ => (main)}/admin/marketplace/page.tsx | 0 app/{ => (main)}/admin/page.tsx | 0 app/{ => (main)}/admin/plugins/[id]/page.tsx | 0 app/{ => (main)}/admin/plugins/page.tsx | 0 app/{ => (main)}/admin/policy/page.tsx | 0 app/{ => (main)}/admin/settings/page.tsx | 0 app/{ => (main)}/admin/telemetry/page.tsx | 0 app/{ => (main)}/admin/themes/page.tsx | 0 app/{ => (main)}/admin/version/page.tsx | 0 app/{ => (main)}/global-error.tsx | 0 app/{ => (main)}/layout.tsx | 2 +- app/{ => (main)}/not-found.tsx | 0 app/{ => (main)}/protocol/mailto/page.tsx | 0 app/{ => (main)}/protocol/webcal/page.tsx | 0 app/{ => (main)}/setup/layout.tsx | 0 app/{ => (main)}/setup/page.tsx | 0 app/{plugin-sandbox => (sandbox)}/layout.tsx | 0 app/(sandbox)/plugin-sandbox/page.tsx | 10 +++++ app/api/admin/plugins/route.ts | 13 +++++- app/api/plugins/route.ts | 11 ++++- app/plugin-sandbox/page.tsx | 7 ---- lib/plugin-sandbox/host-bridge.ts | 10 ++++- lib/plugin-sandbox/loader.ts | 41 ++++++++++++++++--- lib/plugin-sandbox/protocol.ts | 11 +++-- lib/plugin-sandbox/runtime.tsx | 13 +++--- proxy.ts | 6 ++- stores/plugin-store.ts | 12 ++++-- 57 files changed, 109 insertions(+), 37 deletions(-) rename app/{ => (main)}/[locale]/auth/callback/page.tsx (100%) rename app/{ => (main)}/[locale]/calendar/page.tsx (100%) rename app/{ => (main)}/[locale]/contacts/page.tsx (100%) rename app/{ => (main)}/[locale]/error.tsx (100%) rename app/{ => (main)}/[locale]/files/page.tsx (100%) rename app/{ => (main)}/[locale]/layout.tsx (100%) rename app/{ => (main)}/[locale]/login/page.tsx (100%) rename app/{ => (main)}/[locale]/page.tsx (100%) rename app/{ => (main)}/[locale]/pro/page.tsx (97%) rename app/{ => (main)}/[locale]/settings/page.tsx (100%) rename app/{ => (main)}/admin/_tabs/_jmap-servers-section.tsx (100%) rename app/{ => (main)}/admin/_tabs/auth.tsx (100%) rename app/{ => (main)}/admin/_tabs/branding.tsx (100%) rename app/{ => (main)}/admin/_tabs/dashboard.tsx (100%) rename app/{ => (main)}/admin/_tabs/logs.tsx (100%) rename app/{ => (main)}/admin/_tabs/marketplace.tsx (100%) rename app/{ => (main)}/admin/_tabs/plugin-config-panel.tsx (100%) rename app/{ => (main)}/admin/_tabs/plugins.tsx (100%) rename app/{ => (main)}/admin/_tabs/policy.tsx (100%) rename app/{ => (main)}/admin/_tabs/settings.tsx (100%) rename app/{ => (main)}/admin/_tabs/telemetry.tsx (100%) rename app/{ => (main)}/admin/_tabs/themes.tsx (100%) rename app/{ => (main)}/admin/_tabs/version.tsx (100%) rename app/{ => (main)}/admin/auth/page.tsx (100%) rename app/{ => (main)}/admin/branding/page.tsx (100%) rename app/{ => (main)}/admin/change-password/page.tsx (100%) rename app/{ => (main)}/admin/layout.tsx (100%) rename app/{ => (main)}/admin/login/page.tsx (100%) rename app/{ => (main)}/admin/logs/page.tsx (100%) rename app/{ => (main)}/admin/marketplace/[slug]/page.tsx (100%) rename app/{ => (main)}/admin/marketplace/page.tsx (100%) rename app/{ => (main)}/admin/page.tsx (100%) rename app/{ => (main)}/admin/plugins/[id]/page.tsx (100%) rename app/{ => (main)}/admin/plugins/page.tsx (100%) rename app/{ => (main)}/admin/policy/page.tsx (100%) rename app/{ => (main)}/admin/settings/page.tsx (100%) rename app/{ => (main)}/admin/telemetry/page.tsx (100%) rename app/{ => (main)}/admin/themes/page.tsx (100%) rename app/{ => (main)}/admin/version/page.tsx (100%) rename app/{ => (main)}/global-error.tsx (100%) rename app/{ => (main)}/layout.tsx (99%) rename app/{ => (main)}/not-found.tsx (100%) rename app/{ => (main)}/protocol/mailto/page.tsx (100%) rename app/{ => (main)}/protocol/webcal/page.tsx (100%) rename app/{ => (main)}/setup/layout.tsx (100%) rename app/{ => (main)}/setup/page.tsx (100%) rename app/{plugin-sandbox => (sandbox)}/layout.tsx (100%) create mode 100644 app/(sandbox)/plugin-sandbox/page.tsx delete mode 100644 app/plugin-sandbox/page.tsx diff --git a/app/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx similarity index 100% rename from app/[locale]/auth/callback/page.tsx rename to app/(main)/[locale]/auth/callback/page.tsx diff --git a/app/[locale]/calendar/page.tsx b/app/(main)/[locale]/calendar/page.tsx similarity index 100% rename from app/[locale]/calendar/page.tsx rename to app/(main)/[locale]/calendar/page.tsx diff --git a/app/[locale]/contacts/page.tsx b/app/(main)/[locale]/contacts/page.tsx similarity index 100% rename from app/[locale]/contacts/page.tsx rename to app/(main)/[locale]/contacts/page.tsx diff --git a/app/[locale]/error.tsx b/app/(main)/[locale]/error.tsx similarity index 100% rename from app/[locale]/error.tsx rename to app/(main)/[locale]/error.tsx diff --git a/app/[locale]/files/page.tsx b/app/(main)/[locale]/files/page.tsx similarity index 100% rename from app/[locale]/files/page.tsx rename to app/(main)/[locale]/files/page.tsx diff --git a/app/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx similarity index 100% rename from app/[locale]/layout.tsx rename to app/(main)/[locale]/layout.tsx diff --git a/app/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx similarity index 100% rename from app/[locale]/login/page.tsx rename to app/(main)/[locale]/login/page.tsx diff --git a/app/[locale]/page.tsx b/app/(main)/[locale]/page.tsx similarity index 100% rename from app/[locale]/page.tsx rename to app/(main)/[locale]/page.tsx diff --git a/app/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx similarity index 97% rename from app/[locale]/pro/page.tsx rename to app/(main)/[locale]/pro/page.tsx index 14d2bb79..30cfda7c 100644 --- a/app/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -16,11 +16,11 @@ import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar"; import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store"; import { cn } from "@/lib/utils"; -import MailPage from "@/app/[locale]/page"; -import CalendarPage from "@/app/[locale]/calendar/page"; -import ContactsPage from "@/app/[locale]/contacts/page"; -import FilesPage from "@/app/[locale]/files/page"; -import SettingsPage from "@/app/[locale]/settings/page"; +import MailPage from "@/app/(main)/[locale]/page"; +import CalendarPage from "@/app/(main)/[locale]/calendar/page"; +import ContactsPage from "@/app/(main)/[locale]/contacts/page"; +import FilesPage from "@/app/(main)/[locale]/files/page"; +import SettingsPage from "@/app/(main)/[locale]/settings/page"; import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body"; import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body"; diff --git a/app/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx similarity index 100% rename from app/[locale]/settings/page.tsx rename to app/(main)/[locale]/settings/page.tsx diff --git a/app/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx similarity index 100% rename from app/admin/_tabs/_jmap-servers-section.tsx rename to app/(main)/admin/_tabs/_jmap-servers-section.tsx diff --git a/app/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx similarity index 100% rename from app/admin/_tabs/auth.tsx rename to app/(main)/admin/_tabs/auth.tsx diff --git a/app/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx similarity index 100% rename from app/admin/_tabs/branding.tsx rename to app/(main)/admin/_tabs/branding.tsx diff --git a/app/admin/_tabs/dashboard.tsx b/app/(main)/admin/_tabs/dashboard.tsx similarity index 100% rename from app/admin/_tabs/dashboard.tsx rename to app/(main)/admin/_tabs/dashboard.tsx diff --git a/app/admin/_tabs/logs.tsx b/app/(main)/admin/_tabs/logs.tsx similarity index 100% rename from app/admin/_tabs/logs.tsx rename to app/(main)/admin/_tabs/logs.tsx diff --git a/app/admin/_tabs/marketplace.tsx b/app/(main)/admin/_tabs/marketplace.tsx similarity index 100% rename from app/admin/_tabs/marketplace.tsx rename to app/(main)/admin/_tabs/marketplace.tsx diff --git a/app/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx similarity index 100% rename from app/admin/_tabs/plugin-config-panel.tsx rename to app/(main)/admin/_tabs/plugin-config-panel.tsx diff --git a/app/admin/_tabs/plugins.tsx b/app/(main)/admin/_tabs/plugins.tsx similarity index 100% rename from app/admin/_tabs/plugins.tsx rename to app/(main)/admin/_tabs/plugins.tsx diff --git a/app/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx similarity index 100% rename from app/admin/_tabs/policy.tsx rename to app/(main)/admin/_tabs/policy.tsx diff --git a/app/admin/_tabs/settings.tsx b/app/(main)/admin/_tabs/settings.tsx similarity index 100% rename from app/admin/_tabs/settings.tsx rename to app/(main)/admin/_tabs/settings.tsx diff --git a/app/admin/_tabs/telemetry.tsx b/app/(main)/admin/_tabs/telemetry.tsx similarity index 100% rename from app/admin/_tabs/telemetry.tsx rename to app/(main)/admin/_tabs/telemetry.tsx diff --git a/app/admin/_tabs/themes.tsx b/app/(main)/admin/_tabs/themes.tsx similarity index 100% rename from app/admin/_tabs/themes.tsx rename to app/(main)/admin/_tabs/themes.tsx diff --git a/app/admin/_tabs/version.tsx b/app/(main)/admin/_tabs/version.tsx similarity index 100% rename from app/admin/_tabs/version.tsx rename to app/(main)/admin/_tabs/version.tsx diff --git a/app/admin/auth/page.tsx b/app/(main)/admin/auth/page.tsx similarity index 100% rename from app/admin/auth/page.tsx rename to app/(main)/admin/auth/page.tsx diff --git a/app/admin/branding/page.tsx b/app/(main)/admin/branding/page.tsx similarity index 100% rename from app/admin/branding/page.tsx rename to app/(main)/admin/branding/page.tsx diff --git a/app/admin/change-password/page.tsx b/app/(main)/admin/change-password/page.tsx similarity index 100% rename from app/admin/change-password/page.tsx rename to app/(main)/admin/change-password/page.tsx diff --git a/app/admin/layout.tsx b/app/(main)/admin/layout.tsx similarity index 100% rename from app/admin/layout.tsx rename to app/(main)/admin/layout.tsx diff --git a/app/admin/login/page.tsx b/app/(main)/admin/login/page.tsx similarity index 100% rename from app/admin/login/page.tsx rename to app/(main)/admin/login/page.tsx diff --git a/app/admin/logs/page.tsx b/app/(main)/admin/logs/page.tsx similarity index 100% rename from app/admin/logs/page.tsx rename to app/(main)/admin/logs/page.tsx diff --git a/app/admin/marketplace/[slug]/page.tsx b/app/(main)/admin/marketplace/[slug]/page.tsx similarity index 100% rename from app/admin/marketplace/[slug]/page.tsx rename to app/(main)/admin/marketplace/[slug]/page.tsx diff --git a/app/admin/marketplace/page.tsx b/app/(main)/admin/marketplace/page.tsx similarity index 100% rename from app/admin/marketplace/page.tsx rename to app/(main)/admin/marketplace/page.tsx diff --git a/app/admin/page.tsx b/app/(main)/admin/page.tsx similarity index 100% rename from app/admin/page.tsx rename to app/(main)/admin/page.tsx diff --git a/app/admin/plugins/[id]/page.tsx b/app/(main)/admin/plugins/[id]/page.tsx similarity index 100% rename from app/admin/plugins/[id]/page.tsx rename to app/(main)/admin/plugins/[id]/page.tsx diff --git a/app/admin/plugins/page.tsx b/app/(main)/admin/plugins/page.tsx similarity index 100% rename from app/admin/plugins/page.tsx rename to app/(main)/admin/plugins/page.tsx diff --git a/app/admin/policy/page.tsx b/app/(main)/admin/policy/page.tsx similarity index 100% rename from app/admin/policy/page.tsx rename to app/(main)/admin/policy/page.tsx diff --git a/app/admin/settings/page.tsx b/app/(main)/admin/settings/page.tsx similarity index 100% rename from app/admin/settings/page.tsx rename to app/(main)/admin/settings/page.tsx diff --git a/app/admin/telemetry/page.tsx b/app/(main)/admin/telemetry/page.tsx similarity index 100% rename from app/admin/telemetry/page.tsx rename to app/(main)/admin/telemetry/page.tsx diff --git a/app/admin/themes/page.tsx b/app/(main)/admin/themes/page.tsx similarity index 100% rename from app/admin/themes/page.tsx rename to app/(main)/admin/themes/page.tsx diff --git a/app/admin/version/page.tsx b/app/(main)/admin/version/page.tsx similarity index 100% rename from app/admin/version/page.tsx rename to app/(main)/admin/version/page.tsx diff --git a/app/global-error.tsx b/app/(main)/global-error.tsx similarity index 100% rename from app/global-error.tsx rename to app/(main)/global-error.tsx diff --git a/app/layout.tsx b/app/(main)/layout.tsx similarity index 99% rename from app/layout.tsx rename to app/(main)/layout.tsx index ec50f651..b98b064c 100644 --- a/app/layout.tsx +++ b/app/(main)/layout.tsx @@ -5,7 +5,7 @@ import { getLocale } from "next-intl/server"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { configManager } from "@/lib/admin/config-manager"; -import "./globals.css"; +import "../globals.css"; const geistSans = Geist({ variable: "--font-geist-sans", diff --git a/app/not-found.tsx b/app/(main)/not-found.tsx similarity index 100% rename from app/not-found.tsx rename to app/(main)/not-found.tsx diff --git a/app/protocol/mailto/page.tsx b/app/(main)/protocol/mailto/page.tsx similarity index 100% rename from app/protocol/mailto/page.tsx rename to app/(main)/protocol/mailto/page.tsx diff --git a/app/protocol/webcal/page.tsx b/app/(main)/protocol/webcal/page.tsx similarity index 100% rename from app/protocol/webcal/page.tsx rename to app/(main)/protocol/webcal/page.tsx diff --git a/app/setup/layout.tsx b/app/(main)/setup/layout.tsx similarity index 100% rename from app/setup/layout.tsx rename to app/(main)/setup/layout.tsx diff --git a/app/setup/page.tsx b/app/(main)/setup/page.tsx similarity index 100% rename from app/setup/page.tsx rename to app/(main)/setup/page.tsx diff --git a/app/plugin-sandbox/layout.tsx b/app/(sandbox)/layout.tsx similarity index 100% rename from app/plugin-sandbox/layout.tsx rename to app/(sandbox)/layout.tsx diff --git a/app/(sandbox)/plugin-sandbox/page.tsx b/app/(sandbox)/plugin-sandbox/page.tsx new file mode 100644 index 00000000..fa9576ee --- /dev/null +++ b/app/(sandbox)/plugin-sandbox/page.tsx @@ -0,0 +1,10 @@ +import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; + +// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in +// Next's injected hydration/chunk scripts. With force-static, those scripts +// render without a nonce and the strict sandbox CSP blocks them. +export const dynamic = 'force-dynamic'; + +export default function PluginSandboxPage() { + return ; +} diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 78c7e8b6..7f03c047 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -240,9 +240,18 @@ export async function PATCH(request: NextRequest) { if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled; const { updatePluginMeta } = await import('@/lib/admin/plugin-registry'); - const updated = await updatePluginMeta(id, updates); + let updated = await updatePluginMeta(id, updates); if (!updated) { - return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); + // Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but + // forceEnabled is canonical-stored in policy.forceEnabledPlugins on the + // client. Skip the registry write and return the live dev plugin so the + // policy save path can proceed. + const devEntries = await listDevPlugins(); + const devEntry = devEntries.find(e => e.plugin.id === id); + if (!devEntry) { + return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); + } + updated = { ...devEntry.plugin, ...updates }; } // Enable/disable changes the set of plugins contributing frame origins. diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 22c1154a..2cde26c6 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry'; import { listDevPlugins } from '@/lib/admin/plugin-dev'; +import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; /** @@ -11,6 +12,10 @@ import { logger } from '@/lib/logger'; */ export async function GET() { try { + await configManager.ensureLoaded(); + const policy = configManager.getPolicy(); + const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []); + const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([ getPluginRegistry(), getThemeRegistry(), @@ -34,7 +39,11 @@ export async function GET() { type: p.type, permissions: p.permissions, entrypoint: p.entrypoint, - forceEnabled: p.forceEnabled || false, + // Policy is the canonical source for force-enable. The per-plugin field + // can drift for dev plugins (manifest always loads forceEnabled:false) + // and during pending policy saves; OR'ing here unifies the signal so + // the client's auto-enable path triggers consistently. + forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id), // Content hash + updatedAt let clients detect re-uploads even when // the manifest version is unchanged. bundleHash: p.bundleHash, diff --git a/app/plugin-sandbox/page.tsx b/app/plugin-sandbox/page.tsx deleted file mode 100644 index 6ddfd078..00000000 --- a/app/plugin-sandbox/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; - -export const dynamic = 'force-static'; - -export default function PluginSandboxPage() { - return ; -} diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 365ce1de..a421a136 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -118,7 +118,15 @@ export class SandboxInstance { }); this.iframe = document.createElement('iframe'); - this.iframe.setAttribute('sandbox', 'allow-scripts'); + // Dev-only: Next's HMR/dev runtime refuses requests from the opaque + // ("null") origin a strict sandbox produces, so the iframe never + // hydrates and `sandbox-ready` is never posted. Add allow-same-origin + // in dev so the iframe shares the host's origin and HMR works. + // Production keeps the strict opaque-origin sandbox. + const sandboxFlags = process.env.NODE_ENV === 'development' + ? 'allow-scripts allow-same-origin' + : 'allow-scripts'; + this.iframe.setAttribute('sandbox', sandboxFlags); this.iframe.setAttribute('referrerpolicy', 'no-referrer'); this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`; this.iframe.style.border = 'none'; diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 18c41bbd..16894bb8 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -65,20 +65,44 @@ async function getBundleCode(plugin: InstalledPlugin): Promise { // ─── Load ───────────────────────────────────────────────────── +// Bound on how long the sandbox iframe may take to send back init-done. +// Without this a single misbehaving plugin can hang the whole load loop. +// 30s accommodates Next.js dev-mode per-iframe compile + SSR + hydrate on +// slower machines, while still catching truly stuck plugins. +const INIT_TIMEOUT_MS = 30_000; + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${ms}ms`)); + }, ms); + promise.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); +} + export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { if (typeof window === 'undefined') return; + let background: ReturnType | null = null; try { const code = await getBundleCode(plugin); - const background = createBackgroundInstance({ + background = createBackgroundInstance({ plugin, code, locale: currentLocale, }); // Wait for the background runtime to evaluate the bundle, register hooks, - // and enumerate slots. - const info = await background.initPromise; + // and enumerate slots. Bounded so a stuck iframe doesn't hang activation. + const bg = background; + const info = await withTimeout( + bg.initPromise, + INIT_TIMEOUT_MS, + `[plugin-sandbox] "${plugin.id}" init`, + ); // Wire hook proxies: every hookName the plugin registered gets a HookBus // entry whose handler dispatches into the sandbox. `shortcut:` hooks @@ -93,7 +117,7 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { try { - return await background.invokeHook(hookName, args); + return await bg.invokeHook(hookName, args); } catch (err) { pluginErrorTracker.record(plugin.id, err); throw err; @@ -103,13 +127,13 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise void; reject: (err: Error) => void }>(); const pendingCallbacks = new Map void; reject: (err: Error) => void }>(); @@ -436,13 +439,13 @@ function handleHostMessage(ev: MessageEvent): void { // ─── React entry ───────────────────────────────────────────── export function SandboxRuntime(): React.JSX.Element { - const inited = useRef(false); useEffect(() => { - if (inited.current) return; - inited.current = true; window.addEventListener('message', handleHostMessage); // Initial ping. We don't know parent origin yet, so '*' is required. - if (window.parent && window.parent !== window) { + // Guard at module scope so React strict mode's double-invoke doesn't + // re-post (and so a re-post can't race with the parent's init reply). + if (!readyPosted && window.parent && window.parent !== window) { + readyPosted = true; window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*'); } return () => { diff --git a/proxy.ts b/proxy.ts index 99093399..814abbd5 100644 --- a/proxy.ts +++ b/proxy.ts @@ -119,6 +119,10 @@ export async function proxy(request: NextRequest) { const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/'); const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/'); const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/'); + // The plugin sandbox lives in its own root layout under app/(sandbox)/ and + // is not part of the localized tree. Letting next-intl rewrite the path to + // /en/plugin-sandbox 404s, which kills the iframe and disables every plugin. + const isSandboxRoute = isSandboxPath; // When localePrefix is 'always', paths that already have a locale prefix // (e.g. /en/settings) should not be re-processed by the intl middleware - @@ -129,7 +133,7 @@ export async function proxy(request: NextRequest) { ); let intlResponse: ReturnType | null = null; - if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !hasLocalePrefix) { + if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !isSandboxRoute && !hasLocalePrefix) { try { intlResponse = intlMiddleware(request); } catch (error) { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index b8f56d4d..2d2303db 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -262,11 +262,11 @@ export const usePluginStore = create()( // Sync server-managed plugins before loading await syncServerPlugins(get, set); - // Load all enabled plugins + // Load all enabled plugins in parallel. Sequential `await` made one + // hung/slow plugin block every subsequent one; loadSandboxedPlugin + // catches its own errors so allSettled is just for tidy completion. const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error'); - for (const plugin of enabledPlugins) { - await loadPlugin(plugin); - } + await Promise.allSettled(enabledPlugins.map(plugin => loadPlugin(plugin))); set({ initialized: true }); })(); @@ -474,6 +474,9 @@ async function syncServerPlugins( ), })); } else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) { + // When forceEnabled flips on, enable the plugin in the same pass so + // the user doesn't need a second refresh for it to run. + const shouldAutoEnable = sp.forceEnabled && !local.enabled; set(state => ({ plugins: state.plugins.map(p => p.id === sp.id @@ -482,6 +485,7 @@ async function syncServerPlugins( managed: true, forceEnabled: sp.forceEnabled, settingsSchema: sp.settingsSchema, + ...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}), } : p ), From 50088578806691004c2ce90c8320294beb24c4d1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 23:53:44 +0200 Subject: [PATCH 07/44] fix: respect server-resolved locale on first visit #309 --- components/providers/intl-provider.tsx | 6 ++++-- stores/locale-store.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx index 60193c61..d378803c 100644 --- a/components/providers/intl-provider.tsx +++ b/components/providers/intl-provider.tsx @@ -51,7 +51,7 @@ interface IntlProviderProps { export function IntlProvider({ locale: initialLocale, children }: IntlProviderProps) { const currentLocale = useLocaleStore((state) => state.locale); const setLocale = useLocaleStore((state) => state.setLocale); - const [activeLocale, setActiveLocale] = useState(currentLocale || initialLocale); + const [activeLocale, setActiveLocale] = useState(initialLocale); const [timeZone, setTimeZone] = useState('UTC'); // Detect user's timezone on mount @@ -66,10 +66,12 @@ export function IntlProvider({ locale: initialLocale, children }: IntlProviderPr } }, []); - // Sync initial locale with store on first mount only + // First mount: seed the store from the server-resolved locale if nothing is persisted. useEffect(() => { if (!currentLocale) { setLocale(initialLocale); + } else { + setActiveLocale(currentLocale); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/stores/locale-store.ts b/stores/locale-store.ts index 7cc5e96b..3dab55a0 100644 --- a/stores/locale-store.ts +++ b/stores/locale-store.ts @@ -9,7 +9,7 @@ interface LocaleStore { export const useLocaleStore = create()( persist( (set) => ({ - locale: 'en', + locale: '', setLocale: (locale) => set({ locale }), }), { From d854b903e086262515a8fe219ad10234be06344a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 23:59:10 +0200 Subject: [PATCH 08/44] fix: anchor unmatched URLs into main so 404 renders --- app/(main)/[...rest]/page.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/(main)/[...rest]/page.tsx diff --git a/app/(main)/[...rest]/page.tsx b/app/(main)/[...rest]/page.tsx new file mode 100644 index 00000000..093739e8 --- /dev/null +++ b/app/(main)/[...rest]/page.tsx @@ -0,0 +1,10 @@ +import { notFound } from 'next/navigation'; + +// Catch-all that anchors unmatched URLs into the (main) route group so +// Next renders app/(main)/not-found.tsx (wrapped by (main)/layout.tsx) +// instead of the built-in __next_builtin__not-found page. Without this, +// route groups can't pick a root layout for URLs that match nothing, so +// 404s render bare. +export default function CatchAll() { + notFound(); +} From 9763ffa2a33de993bda0b4b95038e8884de552c2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 15:39:02 +0200 Subject: [PATCH 09/44] fix: keep proInterface per-device instead of syncing it --- stores/settings-store.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 3b013192..3c2ebd7a 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -43,6 +43,14 @@ export type MailLayout = 'split' | 'focus' | 'horizontal'; export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s'; export type ProtocolOpenMode = 'active-session' | 'new-tab'; +/** + * Settings that must never round-trip through the cross-device sync API. + * Decided per device and kept only in the local zustand-persist storage — + * a value already stored on the server (from a prior build) is ignored on + * import. + */ +const DEVICE_LOCAL_SETTING_KEYS = new Set(['proInterface']); + export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam'; export type HoverActionsMode = 'inline' | 'floating'; export type HoverActionsCorner = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; @@ -512,7 +520,8 @@ export const useSettingsStore = create()( toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher, showRailAccountList: state.showRailAccountList, - proInterface: state.proInterface, + // proInterface is intentionally omitted — it's a per-device choice + // (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced. enableUnifiedMailbox: state.enableUnifiedMailbox, senderFavicons: state.senderFavicons, showAvatarsInJunk: state.showAvatarsInJunk, @@ -557,6 +566,9 @@ export const useSettingsStore = create()( if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) { return; } + if (DEVICE_LOCAL_SETTING_KEYS.has(key)) { + return; + } set({ [key]: settings[key] }); } }); From b75bbaa5174716d78e90c8f001a31147a063a85e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 15:47:05 +0200 Subject: [PATCH 10/44] feat: auto-redirect to Pro shell when proInterface is on --- app/(main)/[locale]/layout.tsx | 2 ++ app/(main)/[locale]/pro/page.tsx | 10 ++++-- components/pro/pro-interface-redirect.tsx | 38 +++++++++++++++++++++++ components/settings/layout-settings.tsx | 21 +++---------- 4 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 components/pro/pro-interface-redirect.tsx diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 238de5b7..60944ea5 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -6,6 +6,7 @@ import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-p import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider"; import { TourProvider } from "@/components/tour/tour-provider"; import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider"; +import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { locales } from "@/i18n/routing"; @@ -36,6 +37,7 @@ export default async function LocaleLayout({ + {children} diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index 30cfda7c..c4c9b51e 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -9,6 +9,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { EmbeddedContext } from "@/hooks/use-is-embedded"; import { PaneSizeContext } from "@/hooks/use-pane-size"; @@ -128,6 +129,7 @@ export default function ProHome() { const authLoading = useAuthStore((s) => s.isLoading); const quota = useEmailStore((s) => s.quota); const isPushConnected = useEmailStore((s) => s.isPushConnected); + const proInterface = useSettingsStore((s) => s.proInterface); const tabs = useProTabStore((s) => s.tabs); const activeMainTabId = useProTabStore((s) => s.activeTabId); @@ -165,10 +167,14 @@ export default function ProHome() { }, [initialCheckDone, isAuthenticated, authLoading]); useEffect(() => { - if (initialCheckDone && (isMobile || isTablet) && typeof window !== "undefined") { + if (!initialCheckDone || typeof window === "undefined") return; + // Pro is desktop-only, and only used when the user has explicitly + // enabled it. If either precondition stops holding, hand the user back + // to the standard shell. + if (isMobile || isTablet || !proInterface) { window.location.replace("/"); } - }, [initialCheckDone, isMobile, isTablet]); + }, [initialCheckDone, isMobile, isTablet, proInterface]); const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]); const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]); diff --git a/components/pro/pro-interface-redirect.tsx b/components/pro/pro-interface-redirect.tsx new file mode 100644 index 00000000..098fe219 --- /dev/null +++ b/components/pro/pro-interface-redirect.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "@/i18n/navigation"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useIsDesktop } from "@/hooks/use-media-query"; +import { useProTabStore, type ProTabKind } from "@/stores/pro-tab-store"; + +const STANDARD_PATH_TO_TAB: Record> = { + '/': 'mail', + '/calendar': 'calendar', + '/contacts': 'contacts', + '/files': 'files', + '/settings': 'settings', +}; + +/** + * When the Pro interface is enabled, the standard mail/calendar/contacts/ + * files/settings routes are taken over by the Pro shell — the user shouldn't + * have to click "Open" in settings to land there. Mobile/tablet keeps the + * standard layout because Pro is desktop-only (see pro/page.tsx). + */ +export function ProInterfaceRedirect() { + const router = useRouter(); + const pathname = usePathname(); + const proInterface = useSettingsStore((s) => s.proInterface); + const isDesktop = useIsDesktop(); + + useEffect(() => { + if (!proInterface || !isDesktop) return; + const tabKind = STANDARD_PATH_TO_TAB[pathname]; + if (!tabKind) return; + useProTabStore.getState().openTab(tabKind); + router.replace('/pro'); + }, [proInterface, isDesktop, pathname, router]); + + return null; +} diff --git a/components/settings/layout-settings.tsx b/components/settings/layout-settings.tsx index 213cf1a0..a3ac41c6 100644 --- a/components/settings/layout-settings.tsx +++ b/components/settings/layout-settings.tsx @@ -1,13 +1,11 @@ "use client"; import { useTranslations } from 'next-intl'; -import { Link } from '@/i18n/navigation'; import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { cn } from '@/lib/utils'; import { usePolicyStore } from '@/stores/policy-store'; import { useAccountStore } from '@/stores/account-store'; -import { useMediaQuery } from '@/hooks/use-media-query'; const MAIL_LAYOUT_PREVIEW_ROWS = [ { sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false }, @@ -120,7 +118,6 @@ export function LayoutSettings() { const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore(); const accounts = useAccountStore(s => s.accounts); - const isDesktop = useMediaQuery('(min-width: 1024px)'); return ( @@ -193,20 +190,10 @@ export function LayoutSettings() { )} -
- {proInterface && isDesktop && ( - - {t('pro_interface.open_label')} - - )} - updateSetting('proInterface', v)} - /> -
+ updateSetting('proInterface', v)} + />
); From eca837962c6ccc0716401d0a0c788a2da53b8e10 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 15:49:09 +0200 Subject: [PATCH 11/44] fix: hide "Back to Mail" in settings when Pro mode is on --- app/(main)/[locale]/settings/page.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 7f58d935..9d129369 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -363,6 +363,7 @@ export default function SettingsPage() { const installedPlugins = usePluginStore((s) => s.plugins); const installedThemes = useThemeStore((s) => s.installedThemes); const sidebarAppsList = useSettingsStore((s) => s.sidebarApps); + const proInterface = useSettingsStore((s) => s.proInterface); // Build a per-tab haystack for fulltext search and a list of sub-results // (individual settings) per tab. Sub-results come from translation entries @@ -866,17 +867,19 @@ export default function SettingsPage() { )} style={{ width: `${settingsSidebarWidth}px` }} > -
- -
+ {!proInterface && ( +
+ +
+ )}
From eb6e5f589e9bf0e7fedd97835f4c16684ebdbb1b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 15:54:33 +0200 Subject: [PATCH 12/44] fix: hide redundant account switcher in mail sidebar inside Pro shell --- components/layout/sidebar.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 72f422ed..0fc3133d 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -52,6 +52,7 @@ import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; import { AccountSwitcher } from "./account-switcher"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useTour } from "@/components/tour/tour-provider"; interface SidebarProps { @@ -696,7 +697,11 @@ export function Sidebar({ } catch { return new Set(); } }); const emailKeywords = useSettingsStore(s => s.emailKeywords); - const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher); + const isEmbedded = useIsEmbedded(); + // The Pro shell owns the global chrome (rail + tab bar), so the sidebar's + // own AccountSwitcher would be a redundant second account UI in the same + // pane. + const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded; const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox); const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons); const tagCounts = useEmailStore(s => s.tagCounts); From ed90e096b5cb4f7c9fdc2d3c764ffb90139bf27c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 16:11:26 +0200 Subject: [PATCH 13/44] feat: add per-account mailbox cache for Pro shell data layer --- hooks/use-pro-multi-account-mailboxes.ts | 39 +++++ .../email-store-multi-account.test.ts | 144 ++++++++++++++++++ stores/email-store.ts | 61 ++++++++ 3 files changed, 244 insertions(+) create mode 100644 hooks/use-pro-multi-account-mailboxes.ts create mode 100644 stores/__tests__/email-store-multi-account.test.ts diff --git a/hooks/use-pro-multi-account-mailboxes.ts b/hooks/use-pro-multi-account-mailboxes.ts new file mode 100644 index 00000000..ea44916f --- /dev/null +++ b/hooks/use-pro-multi-account-mailboxes.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; + +/** + * Keeps `useEmailStore.accountMailboxes` populated with one entry per + * connected account while the Pro shell is the active interface. The Pro + * sidebar reads this cache to render a Thunderbird-style per-account folder + * tree (see [[project_pro_mode]]). Outside Pro the cache stays empty. + * + * Refetches whenever the set of connected accounts changes, so adding or + * removing an account in another tab is reflected without a reload. + */ +export function useProMultiAccountMailboxes(): void { + const isEmbedded = useIsEmbedded(); + const proInterface = useSettingsStore((s) => s.proInterface); + const accounts = useAccountStore((s) => s.accounts); + + useEffect(() => { + if (!proInterface && !isEmbedded) return; + + const connected = accounts.filter((a) => a.isConnected); + if (connected.length === 0) return; + + const fetchAccountMailboxes = useEmailStore.getState().fetchAccountMailboxes; + const getClientForAccount = useAuthStore.getState().getClientForAccount; + + for (const account of connected) { + const client = getClientForAccount(account.id); + if (!client) continue; + void fetchAccountMailboxes(client, account.id); + } + }, [proInterface, isEmbedded, accounts]); +} diff --git a/stores/__tests__/email-store-multi-account.test.ts b/stores/__tests__/email-store-multi-account.test.ts new file mode 100644 index 00000000..02a5cacb --- /dev/null +++ b/stores/__tests__/email-store-multi-account.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import type { Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +function makeMailbox(overrides: Partial = {}): Mailbox { + return { + id: overrides.id ?? 'inbox', + name: overrides.name ?? 'Inbox', + sortOrder: 0, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + myRights: { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: true, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: true, + mayRename: true, + mayDelete: true, + maySubmit: true, + }, + isSubscribed: true, + isShared: false, + ...overrides, + }; +} + +describe('useEmailStore multi-account state', () => { + beforeEach(() => { + useEmailStore.setState({ + accountMailboxes: {}, + viewingAccountId: null, + selectedMailbox: '', + selectedEmail: null, + selectedEmailIds: new Set(), + selectedKeyword: null, + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + isLoadingThread: null, + }); + }); + + it('caches mailboxes per account via setAccountMailboxes', () => { + const accountA = [makeMailbox({ id: 'a-inbox', name: 'A Inbox' })]; + const accountB = [makeMailbox({ id: 'b-inbox', name: 'B Inbox' })]; + + useEmailStore.getState().setAccountMailboxes('account-a', accountA); + useEmailStore.getState().setAccountMailboxes('account-b', accountB); + + expect(useEmailStore.getState().accountMailboxes).toEqual({ + 'account-a': accountA, + 'account-b': accountB, + }); + }); + + it('replaces the cached entry when setAccountMailboxes is called again', () => { + const initial = [makeMailbox({ id: 'a-inbox' })]; + const updated = [makeMailbox({ id: 'a-inbox' }), makeMailbox({ id: 'a-sent', name: 'Sent' })]; + + useEmailStore.getState().setAccountMailboxes('account-a', initial); + useEmailStore.getState().setAccountMailboxes('account-a', updated); + + expect(useEmailStore.getState().accountMailboxes['account-a']).toEqual(updated); + }); + + it('clearAccountMailboxes wipes the entire cache', () => { + useEmailStore.getState().setAccountMailboxes('account-a', [makeMailbox()]); + useEmailStore.getState().setAccountMailboxes('account-b', [makeMailbox()]); + + useEmailStore.getState().clearAccountMailboxes(); + + expect(useEmailStore.getState().accountMailboxes).toEqual({}); + }); + + it('setViewingAccount updates viewingAccountId without touching the mailbox cache', () => { + useEmailStore.getState().setAccountMailboxes('account-a', [makeMailbox()]); + useEmailStore.getState().setViewingAccount('account-a'); + expect(useEmailStore.getState().viewingAccountId).toBe('account-a'); + expect(useEmailStore.getState().accountMailboxes['account-a']).toBeDefined(); + + useEmailStore.getState().setViewingAccount(null); + expect(useEmailStore.getState().viewingAccountId).toBeNull(); + }); + + it('selectAccountMailbox sets viewing and selected together, and clears email selection state', () => { + useEmailStore.setState({ + selectedEmail: { id: 'e1' } as unknown as ReturnType['selectedEmail'], + selectedEmailIds: new Set(['e1', 'e2']), + selectedKeyword: 'work', + expandedThreadIds: new Set(['thread-1']), + }); + + useEmailStore.getState().selectAccountMailbox('account-b', 'b-inbox'); + + const state = useEmailStore.getState(); + expect(state.viewingAccountId).toBe('account-b'); + expect(state.selectedMailbox).toBe('b-inbox'); + expect(state.selectedEmail).toBeNull(); + expect(state.selectedEmailIds.size).toBe(0); + expect(state.selectedKeyword).toBeNull(); + expect(state.expandedThreadIds.size).toBe(0); + }); + + it('selectAccountMailbox with null accountId switches back to the active account', () => { + useEmailStore.getState().selectAccountMailbox('account-b', 'b-inbox'); + expect(useEmailStore.getState().viewingAccountId).toBe('account-b'); + + useEmailStore.getState().selectAccountMailbox(null, 'a-inbox'); + expect(useEmailStore.getState().viewingAccountId).toBeNull(); + expect(useEmailStore.getState().selectedMailbox).toBe('a-inbox'); + }); + + it('fetchAccountMailboxes caches the result keyed by accountId', async () => { + const mailboxes = [makeMailbox({ id: 'a-inbox' }), makeMailbox({ id: 'a-sent', name: 'Sent' })]; + const client = { + getMailboxes: vi.fn().mockResolvedValue(mailboxes), + } as unknown as IJMAPClient; + + await useEmailStore.getState().fetchAccountMailboxes(client, 'account-a'); + + expect(client.getMailboxes).toHaveBeenCalledTimes(1); + expect(useEmailStore.getState().accountMailboxes['account-a']).toEqual(mailboxes); + }); + + it('fetchAccountMailboxes leaves the cache untouched when the client throws', async () => { + useEmailStore.getState().setAccountMailboxes('account-a', [makeMailbox({ id: 'a-inbox' })]); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const client = { + getMailboxes: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as IJMAPClient; + + await useEmailStore.getState().fetchAccountMailboxes(client, 'account-a'); + + expect(useEmailStore.getState().accountMailboxes['account-a']).toEqual([ + makeMailbox({ id: 'a-inbox' }), + ]); + consoleError.mockRestore(); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index ea9d7d0f..0282e5f3 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -14,6 +14,21 @@ import { useAccountStore } from "@/stores/account-store"; interface EmailStore { emails: Email[]; mailboxes: Mailbox[]; + /** + * Mailbox caches keyed by accountId. Populated for every connected account + * when the Pro shell is active so the sidebar can render per-account groups + * Thunderbird-style. The active account's mailboxes still live in + * `mailboxes` for back-compat with the single-account view. + */ + accountMailboxes: Record; + /** + * When set, the mail view is reading from this account instead of the + * global active one. `null` means "use the global active account" — i.e. + * the standard single-account behavior. Selecting a folder under a + * non-active account in the Pro sidebar updates this without changing + * `useAuthStore.activeAccountId`. + */ + viewingAccountId: string | null; selectedEmail: Email | null; selectedMailbox: string; isLoading: boolean; @@ -54,6 +69,23 @@ interface EmailStore { setEmails: (emails: Email[]) => void; setMailboxes: (mailboxes: Mailbox[]) => void; + /** Cache or update the mailbox list for a specific account. */ + setAccountMailboxes: (accountId: string, mailboxes: Mailbox[]) => void; + /** Wipe the per-account mailbox cache (e.g. on logout). */ + clearAccountMailboxes: () => void; + setViewingAccount: (accountId: string | null) => void; + /** + * Atomic version of (setViewingAccount + selectMailbox). Pass `null` for + * the active account; pass an accountId to view a non-active account's + * folder without changing the global active account. + */ + selectAccountMailbox: (accountId: string | null, mailboxId: string) => void; + /** + * Fetch mailboxes via the supplied client and store them under + * `accountMailboxes[accountId]`. Used by the Pro shell to populate the + * sidebar's per-account groups for every connected account. + */ + fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise; selectEmail: (email: Email | null) => void; selectMailbox: (mailboxId: string) => void; setLoading: (loading: boolean) => void; @@ -193,6 +225,8 @@ function findTrashMailbox( export const useEmailStore = create((set, get) => ({ emails: [], mailboxes: [], + accountMailboxes: {}, + viewingAccountId: null, selectedEmail: null, selectedMailbox: "", isLoading: false, @@ -236,6 +270,33 @@ export const useEmailStore = create((set, get) => ({ setEmails: (emails) => set({ emails }), setMailboxes: (mailboxes) => set({ mailboxes }), + setAccountMailboxes: (accountId, mailboxes) => set((state) => ({ + accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes }, + })), + clearAccountMailboxes: () => set({ accountMailboxes: {} }), + setViewingAccount: (accountId) => set({ viewingAccountId: accountId }), + selectAccountMailbox: (accountId, mailboxId) => set({ + viewingAccountId: accountId, + selectedMailbox: mailboxId, + selectedEmail: null, + selectedEmailIds: new Set(), + selectedKeyword: null, + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + isLoadingThread: null, + }), + fetchAccountMailboxes: async (client, accountId) => { + try { + const mailboxes = await client.getMailboxes(); + // Re-check the cache after the await to avoid stomping a more recent + // fetch that finished while this one was in flight. + set((state) => ({ + accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes }, + })); + } catch (error) { + console.error(`Failed to fetch mailboxes for account ${accountId}:`, error); + } + }, selectEmail: (email) => { const prev = get().selectedEmail; set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }); From 426d344aa8d5bc733ad7cbc2e3293dffc6fbc916 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 16:52:03 +0200 Subject: [PATCH 14/44] feat: multi-account mail sidebar and client routing for Pro shell --- app/(main)/[locale]/page.tsx | 42 +++++ components/layout/sidebar.tsx | 176 ++++++++++++++++----- stores/email-store.ts | 281 ++++++++++++++++++++++++---------- 3 files changed, 377 insertions(+), 122 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 31253d06..0b974798 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -51,6 +51,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIdentitySync } from "@/hooks/use-identity-sync"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useProTabStore } from "@/stores/pro-tab-store"; +import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes"; import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; @@ -281,8 +282,16 @@ export default function Home() { batchMarkAsRead, batchMarkAsSpam, batchUndoSpam, + accountMailboxes, + viewingAccountId, + selectAccountMailbox, + setViewingAccount, } = useEmailStore(); + // Pro shell: populate per-account mailbox cache so the sidebar can render + // every connected account Thunderbird-style. + useProMultiAccountMailboxes(); + const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); const accounts = useAccountStore((s) => s.accounts); const connectedAccountsSignature = useMemo( @@ -1418,6 +1427,35 @@ export default function Home() { } }; + // Whenever the global active account changes, drop any non-active viewing + // override so we don't leave the email list pointed at a now-stale id. + useEffect(() => { + if (viewingAccountId && viewingAccountId === activeAccountId) { + setViewingAccount(null); + } + }, [activeAccountId, viewingAccountId, setViewingAccount]); + + // Pro sidebar: user clicked a folder under a specific account group. + // accountId === null means the active account; non-null means a viewing + // override that fetches via that account's JMAP client. + const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string) => { + const viewingClient = accountId + ? useAuthStore.getState().getClientForAccount(accountId) ?? client + : client; + selectAccountMailbox(accountId, mailboxId); + selectEmail(null); + if (isMobile) { + setSidebarOpen(false); + setActiveView("list"); + } + if (isTablet) { + setTabletListVisible(true); + } + if (viewingClient) { + await fetchEmails(viewingClient, mailboxId); + } + }; + const handleMailboxSelect = async (mailboxId: string) => { if (isUnifiedMailboxId(mailboxId)) { const role = UNIFIED_ROLE_BY_ID[mailboxId]; @@ -2190,6 +2228,10 @@ export default function Home() { } }} onSidebarClose={() => setSidebarOpen(false)} + multiAccountMode={isEmbedded} + accountMailboxes={accountMailboxes} + viewingAccountId={viewingAccountId} + onAccountMailboxSelect={handleAccountMailboxSelect} />
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 0fc3133d..e7a51701 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -75,6 +75,20 @@ interface SidebarProps { onImportEmail?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; className?: string; + /** + * Multi-account (Pro) mode props. When `multiAccountMode` is true, the + * sidebar renders a per-connected-account group instead of a single + * folders section — Thunderbird-style. `accountMailboxes` provides the + * mailbox list for non-active accounts (the active account still flows + * through the `mailboxes` prop). `viewingAccountId` highlights which + * account's folder is currently selected (null = active account). + * `onAccountMailboxSelect` fires with the owning accountId when the user + * picks a folder; callers translate that into `selectAccountMailbox`. + */ + multiAccountMode?: boolean; + accountMailboxes?: Record; + viewingAccountId?: string | null; + onAccountMailboxSelect?: (accountId: string | null, mailboxId: string) => void; } const ROW_PX_BASE = 8; @@ -661,10 +675,14 @@ export function Sidebar({ onImportEmail, onRefreshMailboxes, className, + multiAccountMode = false, + accountMailboxes, + viewingAccountId = null, + onAccountMailboxSelect, }: SidebarProps) { const router = useRouter(); const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); - const { primaryIdentity: _primaryIdentity } = useAuthStore(); + const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [foldersExpanded, setFoldersExpanded] = useState(() => { try { @@ -696,6 +714,17 @@ export function Sidebar({ return stored !== null ? new Set(JSON.parse(stored) as string[]) : new Set(); } catch { return new Set(); } }); + // Per-connected-account collapse state for Pro / Thunderbird-style mode. + // Stored as the set of accountIds the user has explicitly collapsed — + // anything not in the set is treated as expanded. Inverting the storage + // model lets new accounts default to expanded automatically. + const [collapsedAccountGroups, setCollapsedAccountGroups] = useState>(() => { + try { + const stored = localStorage.getItem('sidebarCollapsedAccountGroups'); + if (stored !== null) return new Set(JSON.parse(stored) as string[]); + } catch { /* fall through */ } + return new Set(); + }); const emailKeywords = useSettingsStore(s => s.emailKeywords); const isEmbedded = useIsEmbedded(); // The Pro shell owns the global chrome (rail + tab bar), so the sidebar's @@ -755,6 +784,24 @@ export function Sidebar({ const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-')); const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-')); + // Multi-account mode (Pro shell): render every connected account as its + // own collapsible group. The active account's tree comes from the + // `mailboxes` prop (which is the live email-store value); other accounts + // come from the per-account cache populated by useProMultiAccountMailboxes. + const useMultiAccount = multiAccountMode && connectedAccounts.length > 1; + const accountGroups = useMultiAccount + ? connectedAccounts.map((account) => { + const isActive = account.id === activeAccountId; + const accountMailboxList = isActive + ? mailboxes + : (accountMailboxes?.[account.id] ?? []); + const tree = buildMailboxTree(accountMailboxList).filter( + (n) => !n.id.startsWith('shared-account-') + ); + return { account, isActive, tree }; + }) + : []; + const getUnifiedIcon = (role: UnifiedMailboxRole) => { switch (role) { case 'inbox': return Inbox; @@ -834,6 +881,14 @@ export function Sidebar({ return next; }); }; + const toggleAccountGroup = (id: string) => { + setCollapsedAccountGroups((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + try { localStorage.setItem('sidebarCollapsedAccountGroups', JSON.stringify(Array.from(next))); } catch { /* */ } + return next; + }); + }; const openFolderSettings = () => { try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ } @@ -937,43 +992,90 @@ export function Sidebar({
)} -
- - {((foldersExpanded && !isCollapsed) || isCollapsed) && ( - <> - {mailboxes.length === 0 ? ( -
- {!isCollapsed && t("loading_mailboxes")} -
- ) : ( - ownTree.map((node) => ( - - )) - )} - - )} -
+ {useMultiAccount ? ( + accountGroups.map(({ account, isActive, tree }) => { + const expanded = !collapsedAccountGroups.has(account.id); + const isViewing = isActive ? viewingAccountId === null : viewingAccountId === account.id; + return ( +
+ toggleAccountGroup(account.id)} + onSettings={isActive ? openFolderSettings : undefined} + settingsTitle={isActive ? t('settings') : undefined} + isCollapsed={isCollapsed} + first={!showUnified && account.id === connectedAccounts[0]?.id} + icon={} + /> + {((expanded && !isCollapsed) || isCollapsed) && ( + <> + {tree.length === 0 ? ( +
+ {!isCollapsed && t("loading_mailboxes")} +
+ ) : ( + tree.map((node) => ( + + onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId) + } + onToggleExpand={handleToggleExpand} + isCollapsed={isCollapsed} + onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined} + colorful={colorfulSidebarIcons} + onContextMenu={isActive ? handleMailboxContextMenu : undefined} + /> + )) + )} + + )} +
+ ); + }) + ) : ( +
+ + {((foldersExpanded && !isCollapsed) || isCollapsed) && ( + <> + {mailboxes.length === 0 ? ( +
+ {!isCollapsed && t("loading_mailboxes")} +
+ ) : ( + ownTree.map((node) => ( + + )) + )} + + )} +
+ )} - {sharedAccounts.length > 0 && ( + {!useMultiAccount && sharedAccounts.length > 0 && (
{ + const viewingId = useEmailStore.getState().viewingAccountId; + const client = resolveActionClient(fallbackClient); + try { + const mailboxes = await client.getMailboxes(); + if (viewingId) { + useEmailStore.setState((state) => ({ + accountMailboxes: { ...state.accountMailboxes, [viewingId]: mailboxes }, + })); + } else { + useEmailStore.setState({ mailboxes }); + } + } catch (error) { + console.error('Failed to refresh mailboxes after mutation:', error); + } +} + // Find the trash mailbox for a given account scope. Prefers JMAP role, but // falls back to name matching ("trash" / "deleted") so users with custom or // pre-existing folders (e.g. "Deleted Items") aren't silently destroyed. @@ -322,7 +370,7 @@ export const useEmailStore = create((set, get) => ({ return; } const tagIds = keywords.map(k => k.id); - const counts = await client.getTagCounts(tagIds); + const counts = await resolveActionClient(client).getTagCounts(tagIds); set({ tagCounts: counts }); } catch (error) { console.error('Failed to fetch tag counts:', error); @@ -446,9 +494,10 @@ export const useEmailStore = create((set, get) => ({ set({ isLoading: true, error: null }); // Keep previous emails visible during transition try { const targetMailboxId = mailboxId || get().selectedMailbox; + const effectiveClient = resolveActionClient(client); // Find the mailbox to get its accountId (for shared folder support) - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === targetMailboxId); // Only pass accountId for shared mailboxes, not for primary account const accountId = mailbox?.isShared ? mailbox.accountId : undefined; @@ -464,7 +513,7 @@ export const useEmailStore = create((set, get) => ({ // When filtering by tag, omit the mailbox constraint so emails across // all folders that carry the tag are returned. - const result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); + const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); set({ emails: result.emails, hasMoreEmails: result.hasMore, @@ -532,6 +581,7 @@ export const useEmailStore = create((set, get) => ({ set({ isLoadingMore: true, error: null }); try { + const effectiveClient = resolveActionClient(client); // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; @@ -544,21 +594,21 @@ export const useEmailStore = create((set, get) => ({ const hasFilters = !isFilterEmpty(searchFilters); if (searchQuery || hasFilters) { - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const jmapMailboxId = mailbox?.originalId || selectedMailbox; const accountId = mailbox?.isShared ? mailbox.accountId : undefined; if (hasFilters) { const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position); + result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position); } else { - result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); + result = await effectiveClient.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); } } else { // Load more from mailbox // Find the mailbox to get its accountId (for shared folder support) - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); // Only pass accountId for shared mailboxes, not for primary account const accountId = mailbox?.isShared ? mailbox.accountId : undefined; @@ -566,7 +616,7 @@ export const useEmailStore = create((set, get) => ({ const jmapMailboxId = mailbox?.originalId || selectedMailbox; // When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails). - result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined); + result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined); } // Use fresh state when merging to avoid overwriting concurrent updates @@ -597,13 +647,13 @@ export const useEmailStore = create((set, get) => ({ try { // Find the selected mailbox to determine accountId (for shared folders) const selectedMailboxId = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId); // Only pass accountId for shared mailboxes const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - const email = await client.getEmail(emailId, accountId); + const email = await resolveActionClient(client).getEmail(emailId, accountId); if (email) { set({ selectedEmail: email }); @@ -619,7 +669,7 @@ export const useEmailStore = create((set, get) => ({ fetchQuota: async (client) => { try { - const quota = await client.getQuota(); + const quota = await resolveActionClient(client).getQuota(); set({ quota }); } catch { // Don't set error state as quota is optional @@ -666,6 +716,7 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isUnread = !email.keywords?.$seen; + const effectiveClient = resolveActionClient(client); // Get delete action preference from settings const deleteAction = useSettingsStore.getState().deleteAction; @@ -673,7 +724,7 @@ export const useEmailStore = create((set, get) => ({ // Determine accountId for shared folders const selectedMailboxId = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; @@ -690,7 +741,7 @@ export const useEmailStore = create((set, get) => ({ if (trashMailbox) { // Use originalId for shared mailboxes if available const trashId = trashMailbox.originalId || trashMailbox.id; - await client.moveToTrash(emailId, trashId, accountId); + await effectiveClient.moveToTrash(emailId, trashId, accountId); // Remove from local state (email moved to trash, not in current view) set((state) => { @@ -737,7 +788,7 @@ export const useEmailStore = create((set, get) => ({ } // Permanent delete - await client.deleteEmail(emailId); + await effectiveClient.deleteEmail(emailId); // Remove from local state and update mailbox counters if needed set((state) => { @@ -811,11 +862,11 @@ export const useEmailStore = create((set, get) => ({ // Determine accountId for shared folders const selectedMailboxId = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - await client.markAsRead(emailId, read, accountId); + await resolveActionClient(client).markAsRead(emailId, read, accountId); // Update local state including mailbox counters set((state) => { @@ -879,14 +930,15 @@ export const useEmailStore = create((set, get) => ({ const isUnread = !email.keywords?.$seen; const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : []; - const { selectedMailbox, mailboxes } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; - await client.moveEmail(emailId, jmapDestId, accountId); + await resolveActionClient(client).moveEmail(emailId, jmapDestId, accountId); set((state) => { const updatedMailboxes = state.mailboxes.map(mailbox => { @@ -933,7 +985,8 @@ export const useEmailStore = create((set, get) => ({ } try { - const { emails, mailboxes, selectedMailbox, isUnifiedView } = get(); + const { emails, selectedMailbox, isUnifiedView } = get(); + const mailboxes = resolveActionMailboxes(); const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; const idSet = new Set(emailIds); @@ -955,7 +1008,7 @@ export const useEmailStore = create((set, get) => ({ } else { const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; - await client.batchMoveEmails(emailIds, jmapDestId, accountId); + await resolveActionClient(client).batchMoveEmails(emailIds, jmapDestId, accountId); } // Adjust counters and drop moved emails from the current view. @@ -1010,12 +1063,14 @@ export const useEmailStore = create((set, get) => ({ return; } - const currentMailbox = state.mailboxes.find(mb => mb.id === state.selectedMailbox); + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); + const currentMailbox = mailboxes.find(mb => mb.id === state.selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; - const destMailbox = state.mailboxes.find(mb => mb.id === destinationMailboxId); + const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; - const thread = await client.getThread(email.threadId, accountId); + const thread = await effectiveClient.getThread(email.threadId, accountId); const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId]; if (threadEmailIds.length <= 1) { @@ -1023,7 +1078,7 @@ export const useEmailStore = create((set, get) => ({ return; } - await client.batchMoveEmails(threadEmailIds, jmapDestId, accountId); + await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId); const removedEmailIds = new Set(threadEmailIds); set((currentState) => { @@ -1057,7 +1112,7 @@ export const useEmailStore = create((set, get) => ({ try { // Get the current mailbox to scope the search const selectedMailbox = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); // Use originalId for shared mailboxes const jmapMailboxId = mailbox?.originalId || selectedMailbox; @@ -1066,7 +1121,7 @@ export const useEmailStore = create((set, get) => ({ // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; - const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); + const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); set({ emails: result.emails, @@ -1088,7 +1143,8 @@ export const useEmailStore = create((set, get) => ({ }, advancedSearch: async (client) => { - const { searchQuery, searchFilters, selectedMailbox, mailboxes, searchAbortController } = get(); + const { searchQuery, searchFilters, selectedMailbox, searchAbortController } = get(); + const mailboxes = resolveActionMailboxes(); if (searchAbortController) { searchAbortController.abort(); @@ -1111,7 +1167,7 @@ export const useEmailStore = create((set, get) => ({ const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); const emailsPerPage = useSettingsStore.getState().emailsPerPage; - const result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0); + const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); if (controller.signal.aborted) return; @@ -1159,7 +1215,7 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isFlagged = email.keywords.$flagged || false; - await client.toggleStar(emailId, !isFlagged); + await resolveActionClient(client).toggleStar(emailId, !isFlagged); // Update local state set((state) => ({ @@ -1191,7 +1247,8 @@ export const useEmailStore = create((set, get) => ({ // Batch operations batchMarkAsRead: async (client, read) => { - const { selectedEmailIds, emails, mailboxes } = get(); + const { selectedEmailIds, emails } = get(); + const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); @@ -1215,7 +1272,7 @@ export const useEmailStore = create((set, get) => ({ }); await Promise.allSettled(promises); } else { - await client.batchMarkAsRead(emailIdsArray, read); + await resolveActionClient(client).batchMarkAsRead(emailIdsArray, read); } // Update local state @@ -1260,7 +1317,8 @@ export const useEmailStore = create((set, get) => ({ }, batchDelete: async (client, permanent = false) => { - const { selectedEmailIds, emails, mailboxes, selectedMailbox } = get(); + const { selectedEmailIds, emails, selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); @@ -1423,7 +1481,7 @@ export const useEmailStore = create((set, get) => ({ }); await Promise.allSettled(promises); } else { - await client.batchMoveEmails(emailIdsArray, toMailboxId); + await resolveActionClient(client).batchMoveEmails(emailIdsArray, toMailboxId); } // Update local state - remove from current view since they moved @@ -1448,7 +1506,8 @@ export const useEmailStore = create((set, get) => ({ }, batchArchive: async (client) => { - const { selectedEmailIds, emails, mailboxes, fetchMailboxes } = get(); + const { selectedEmailIds, emails } = get(); + const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive'); @@ -1462,7 +1521,7 @@ export const useEmailStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { - await client.batchArchiveEmails( + await resolveActionClient(client).batchArchiveEmails( selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })), archiveId, mode, @@ -1473,8 +1532,9 @@ export const useEmailStore = create((set, get) => ({ const remaining = emails.filter(e => !selectedEmailIds.has(e.id)); set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false }); - await fetchMailboxes(client); - // Refresh the current mailbox view (honors active search/filters) + // Refresh the active or viewed account's mailbox cache after the + // archive (a year/month archive can create new sub-folders). + await refreshMailboxesForViewingAccount(client); await get().refreshCurrentMailbox(client); } catch (error) { set({ @@ -1487,7 +1547,8 @@ export const useEmailStore = create((set, get) => ({ // Spam operations markAsSpam: async (client, emailId) => { - const { selectedMailbox, mailboxes, emails } = get(); + const { selectedMailbox, emails } = get(); + const mailboxes = resolveActionMailboxes(); const email = emails.find(e => e.id === emailId); if (!email) return; @@ -1501,7 +1562,7 @@ export const useEmailStore = create((set, get) => ({ }); try { - await client.markAsSpam(emailId, currentMailbox.accountId); + await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId); set(state => ({ emails: state.emails.filter(e => e.id !== emailId), @@ -1514,7 +1575,8 @@ export const useEmailStore = create((set, get) => ({ }, undoSpam: async (client, emailId) => { - const { mailboxes, selectedMailbox } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); // Try cache first (preserves exact original mailbox for toast undo) const cachedData = get().spamUndoCache.get(emailId); @@ -1546,7 +1608,7 @@ export const useEmailStore = create((set, get) => ({ } try { - await client.undoSpam(emailId, targetMailboxId, accountId); + await resolveActionClient(client).undoSpam(emailId, targetMailboxId, accountId); await get().fetchEmails(client, selectedMailbox); } catch (error) { console.error('Failed to restore email:', error); @@ -1555,14 +1617,16 @@ export const useEmailStore = create((set, get) => ({ }, batchMarkAsSpam: async (client, emailIds) => { - const { selectedMailbox, mailboxes } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); if (!currentMailbox) return; try { for (const emailId of emailIds) { - await client.markAsSpam(emailId, currentMailbox.accountId); + await effectiveClient.markAsSpam(emailId, currentMailbox.accountId); } set(state => ({ @@ -1577,7 +1641,9 @@ export const useEmailStore = create((set, get) => ({ }, batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => { - const { mailboxes, selectedMailbox } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); // Find inbox (batch operations don't preserve original mailboxes) const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); @@ -1594,7 +1660,7 @@ export const useEmailStore = create((set, get) => ({ try { for (const emailId of emailIds) { - await client.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId); + await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId); } set(state => ({ @@ -1681,7 +1747,8 @@ export const useEmailStore = create((set, get) => ({ try { // Fetch emails for the current mailbox without clearing the list first // This provides a smoother update experience - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const jmapMailboxId = mailbox?.originalId || selectedMailbox; @@ -1697,9 +1764,9 @@ export const useEmailStore = create((set, get) => ({ let result; if (hasFilters || searchQuery) { const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0); + result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0); } else { - result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0); + result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0); } const currentEmails = get().emails; @@ -1789,7 +1856,8 @@ export const useEmailStore = create((set, get) => ({ }, fetchThreadEmails: async (client, threadId) => { - const { threadEmailsCache, selectedMailbox, mailboxes } = get(); + const { threadEmailsCache, selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); // Check if we already have this thread cached const cachedEmails = threadEmailsCache.get(threadId); @@ -1806,7 +1874,7 @@ export const useEmailStore = create((set, get) => ({ const accountId = mailbox?.isShared ? mailbox.accountId : undefined; // Fetch all emails in the thread - const emails = await client.getThreadEmails(threadId, accountId); + const emails = await resolveActionClient(client).getThreadEmails(threadId, accountId); // Update cache const newCache = new Map(get().threadEmailsCache); @@ -1841,8 +1909,12 @@ export const useEmailStore = create((set, get) => ({ // Mailbox management createMailbox: async (client, name, parentId) => { try { - await client.createMailbox(name, parentId); - await get().fetchMailboxes(client); + await resolveActionClient(client).createMailbox(name, parentId); + if (get().viewingAccountId) { + await refreshMailboxesForViewingAccount(client); + } else { + await get().fetchMailboxes(client); + } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to create folder' }); throw error; @@ -1851,12 +1923,24 @@ export const useEmailStore = create((set, get) => ({ renameMailbox: async (client, mailboxId, name) => { try { - await client.updateMailbox(mailboxId, { name }); - set({ - mailboxes: get().mailboxes.map(mb => - mb.id === mailboxId ? { ...mb, name } : mb - ), - }); + await resolveActionClient(client).updateMailbox(mailboxId, { name }); + const viewingId = get().viewingAccountId; + if (viewingId) { + set((state) => ({ + accountMailboxes: { + ...state.accountMailboxes, + [viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb => + mb.id === mailboxId ? { ...mb, name } : mb + ), + }, + })); + } else { + set({ + mailboxes: get().mailboxes.map(mb => + mb.id === mailboxId ? { ...mb, name } : mb + ), + }); + } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to rename folder' }); throw error; @@ -1865,18 +1949,27 @@ export const useEmailStore = create((set, get) => ({ deleteMailbox: async (client, mailboxId) => { try { - await client.deleteMailbox(mailboxId); - const { mailboxes, selectedMailbox } = get(); - const newMailboxes = mailboxes.filter(mb => mb.id !== mailboxId); - const updates: Partial = { mailboxes: newMailboxes }; - // If the deleted mailbox was selected, switch to inbox - if (selectedMailbox === mailboxId) { - const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared); - if (inbox) { - updates.selectedMailbox = inbox.id; + await resolveActionClient(client).deleteMailbox(mailboxId); + const { selectedMailbox, viewingAccountId: viewingId } = get(); + if (viewingId) { + const updatedList = (get().accountMailboxes[viewingId] ?? []).filter(mb => mb.id !== mailboxId); + const patch: Partial = { + accountMailboxes: { ...get().accountMailboxes, [viewingId]: updatedList }, + }; + if (selectedMailbox === mailboxId) { + const inbox = updatedList.find(mb => mb.role === 'inbox' && !mb.isShared); + if (inbox) patch.selectedMailbox = inbox.id; } + set(patch); + } else { + const newMailboxes = get().mailboxes.filter(mb => mb.id !== mailboxId); + const updates: Partial = { mailboxes: newMailboxes }; + if (selectedMailbox === mailboxId) { + const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared); + if (inbox) updates.selectedMailbox = inbox.id; + } + set(updates); } - set(updates as EmailStore); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to delete folder' }); throw error; @@ -1885,15 +1978,20 @@ export const useEmailStore = create((set, get) => ({ setMailboxRole: async (client, mailboxId, role) => { try { + const effectiveClient = resolveActionClient(client); // If assigning a role, first clear that role from ALL other mailboxes that have it if (role) { - const existingMailboxes = get().mailboxes.filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId); + const existingMailboxes = resolveActionMailboxes().filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId); for (const existing of existingMailboxes) { - await client.updateMailbox(existing.id, { role: null }); + await effectiveClient.updateMailbox(existing.id, { role: null }); } } - await client.updateMailbox(mailboxId, { role }); - await get().fetchMailboxes(client); + await effectiveClient.updateMailbox(mailboxId, { role }); + if (get().viewingAccountId) { + await refreshMailboxesForViewingAccount(client); + } else { + await get().fetchMailboxes(client); + } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to update folder role' }); throw error; @@ -1903,7 +2001,7 @@ export const useEmailStore = create((set, get) => ({ emptyMailbox: async (client, mailboxId) => { try { set({ isLoading: true, error: null }); - await client.emptyMailbox(mailboxId); + await resolveActionClient(client).emptyMailbox(mailboxId); // Clear emails from local state if we're viewing this mailbox const currentMailbox = get().selectedMailbox; @@ -1911,15 +2009,28 @@ export const useEmailStore = create((set, get) => ({ set({ emails: [], selectedEmail: null }); } - // Update mailbox counters - set({ - mailboxes: get().mailboxes.map(mb => - mb.id === mailboxId - ? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 } - : mb - ), - isLoading: false, - }); + const viewingId = get().viewingAccountId; + if (viewingId) { + set((state) => ({ + accountMailboxes: { + ...state.accountMailboxes, + [viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb => + mb.id === mailboxId + ? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 } + : mb + ), + }, + })); + } else { + set({ + mailboxes: get().mailboxes.map(mb => + mb.id === mailboxId + ? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 } + : mb + ), + }); + } + set({ isLoading: false }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to empty folder', @@ -1931,11 +2042,11 @@ export const useEmailStore = create((set, get) => ({ markMailboxAsRead: async (client, mailboxId) => { try { - const mailbox = get().mailboxes.find(mb => mb.id === mailboxId); + const mailbox = resolveActionMailboxes().find(mb => mb.id === mailboxId); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const jmapMailboxId = mailbox?.originalId || mailboxId; - const count = await client.markMailboxAsRead(jmapMailboxId, accountId); + const count = await resolveActionClient(client).markMailboxAsRead(jmapMailboxId, accountId); // Update local state: mark all emails currently visible in this mailbox as read, // and zero-out the mailbox unread counter. From c9435f75804e5665593bed8406e384d596cc3957 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 17:01:34 +0200 Subject: [PATCH 15/44] feat: always show unified mailbox in Pro shell --- app/(main)/[locale]/page.tsx | 8 +++++--- components/layout/sidebar.tsx | 6 +++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 0b974798..d2da95da 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -884,15 +884,17 @@ export default function Home() { // Keep unified mailbox counts in sync when the feature is enabled and more // than one account is connected. Runs whenever the set of connected accounts // or the primary account's mailboxes change (a proxy for "something worth - // recounting happened"). + // recounting happened"). The Pro shell always renders the unified mailbox + // regardless of the user setting, so refresh when embedded too. useEffect(() => { - if (!enableUnifiedMailbox || !isAuthenticated || !client) return; + if (!enableUnifiedMailbox && !isEmbedded) return; + if (!isAuthenticated || !client) return; const built = buildUnifiedAccounts(); if (built.length < 2) return; populateUnifiedAccountMailboxes(built).then((populated) => { refreshUnifiedCounts(populated); }); - }, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]); + }, [enableUnifiedMailbox, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]); // System-notification click handler. The push SW navigates the user back // here with `?email=` (specific email it built the toast from) or diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index e7a51701..0b395750 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -736,7 +736,11 @@ export function Sidebar({ const tagCounts = useEmailStore(s => s.tagCounts); const accounts = useAccountStore(s => s.accounts); const connectedAccounts = accounts.filter(a => a.isConnected); - const showUnified = enableUnifiedMailbox && connectedAccounts.length > 1; + // Pro shell treats the unified mailbox as a core part of the multi-account + // UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The + // 2+ account requirement still applies — with a single account the + // unified counts would just duplicate that account's inbox. + const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1; const { unifiedCounts } = useEmailStore(); const t = useTranslations('sidebar'); From 1756f5ac1cc34a1cc777fb0f1e07e7cbedff7703 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 17:12:06 +0200 Subject: [PATCH 16/44] feat: enable search in unified mailbox in pro mode --- app/(main)/[locale]/page.tsx | 26 +++++---- lib/unified-mailbox.ts | 93 ++++++++++++++++++++++++++++++ stores/email-store.ts | 107 ++++++++++++++++++++++++++++------- 3 files changed, 195 insertions(+), 31 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index d2da95da..1a2a2c17 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1796,7 +1796,6 @@ export default function Home() { const handleSearch = async (query: string) => { if (!client) return; - if (isUnifiedView) return; setSearchQuery(query); if (!isFilterEmpty(searchFilters)) { await advancedSearch(client); @@ -1808,14 +1807,25 @@ export default function Home() { const handleClearSearch = async () => { setSearchQuery(""); clearSearchFilters(); - if (client && selectedMailbox) { + if (!client) return; + // In unified view the active "mailbox" is a virtual role, so refresh via + // the unified fan-out instead of fetchEmails. + if (isUnifiedView) { + const role = useEmailStore.getState().unifiedRole; + if (role) { + const built = buildUnifiedAccounts(); + const populated = await populateUnifiedAccountMailboxes(built); + await fetchUnifiedEmailsAction(populated, role); + } + return; + } + if (selectedMailbox) { await fetchEmails(client, selectedMailbox); } }; const handleAdvancedSearch = async () => { if (!client) return; - if (isUnifiedView) return; await advancedSearch(client); }; @@ -1825,9 +1835,9 @@ export default function Home() { clearTimeout(advancedSearchDebounceRef.current); } advancedSearchDebounceRef.current = setTimeout(() => { - if (client && !isUnifiedView) advancedSearch(client); + if (client) advancedSearch(client); }, 300); - }, [client, advancedSearch, isUnifiedView]); + }, [client, advancedSearch]); useEffect(() => { return () => { @@ -2326,8 +2336,6 @@ export default function Home() { className={cn("pl-9 h-9", searchQuery && "pr-8")} data-search-input data-tour="search-input" - disabled={isUnifiedView} - title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined} /> {searchQuery && ( + )} {/* ── MOBILE TOOLBAR ── */} {isMobile && (
{/* Row 1: Back / Date nav / Today */}
+ {onMenuClick && ( + + )} {onNavigateBack && ( + )} + )} {/* Breadcrumbs */}
+
+ ) : accountPickerMode ? ( +
+

{t("no_accounts")}

+
) : resources.length === 0 && !searchQuery && currentPath === '/' ? ( { diff --git a/components/files/folder-tree-sidebar.tsx b/components/files/folder-tree-sidebar.tsx index d464761d..48013f1c 100644 --- a/components/files/folder-tree-sidebar.tsx +++ b/components/files/folder-tree-sidebar.tsx @@ -130,7 +130,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid return ( - {/* Shared accounts with address books */} - {sharedBookGroups.map((group) => ( + {/* Shared accounts with address books — only when not already split + into per-account groups above (multi-account Pro mode). */} + {!multiAccountMode && sharedBookGroups.map((group) => (
)} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index c99491fa..462fdd9d 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -52,6 +52,8 @@ interface ContactFormProps { addressBooks?: AddressBook[]; allKeywords?: string[]; defaultAddressBookId?: string; + /** Prefills the create form (ignored when `contact` is set). */ + prefill?: { email?: string; name?: string }; onSave: (data: Partial) => Promise; onCancel: () => void; } @@ -145,10 +147,22 @@ function Select({ value, onChange, children, className }: { ); } -export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) { +export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; + // Split a free-form display name into given/surname for prefill. + const prefillGivenName = (() => { + if (contact || !prefill?.name) return ""; + const parts = prefill.name.trim().split(/\s+/); + return parts[0] || ""; + })(); + const prefillSurname = (() => { + if (contact || !prefill?.name) return ""; + const parts = prefill.name.trim().split(/\s+/); + return parts.slice(1).join(" "); + })(); + // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases. const findComponent = (...kinds: string[]) => contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || ""; @@ -215,9 +229,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress } const [prefix, setPrefix] = useState(findComponent("title", "prefix")); - const [givenName, setGivenName] = useState(findComponent("given")); + const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName); const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle")); - const [surname, setSurname] = useState(findComponent("surname")); + const [surname, setSurname] = useState(findComponent("surname") || prefillSurname); const [suffix, setSuffix] = useState(findComponent("generation", "suffix")); const [nickname, setNickname] = useState( @@ -231,7 +245,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "", })); } - return [{ address: "", context: "" }]; + return [{ address: prefill?.email || "", context: "" }]; }); const [phones, setPhones] = useState(() => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ce196519..411468a6 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -65,6 +65,7 @@ import { PenSquare, } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; @@ -1137,9 +1138,34 @@ export function EmailViewer({ const [contactSidebarEmail, setContactSidebarEmail] = useState(null); const contacts = useContactStore((s) => s.contacts); const { isMobile: isMobileDevice } = useDeviceDetection(); + const router = useRouter(); const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => { - if (isMobileDevice) return; // no sidebar on mobile + if (isMobileDevice) { + // No room for a sidebar on mobile — send the user to the contacts page + // with params describing what to show. The `from=email` flag turns the + // page's mobile back button into a router.back() that returns here. + const allRecipients = [ + ...(email?.from || []), + ...(email?.to || []), + ...(email?.cc || []), + ...(email?.bcc || []), + ...(email?.replyTo || []), + ]; + const recipientName = allRecipients.find( + (r) => r.email.toLowerCase() === recipientEmail.toLowerCase() + )?.name; + const params = new URLSearchParams(); + if (contact) { + params.set('contactId', contact.id); + } else { + params.set('addEmail', recipientEmail); + if (recipientName) params.set('addName', recipientName); + } + params.set('from', 'email'); + router.push(`/contacts?${params.toString()}`); + return; + } setContactSidebarEmail(recipientEmail); }; diff --git a/locales/cs/common.json b/locales/cs/common.json index ee0d0883..af67b846 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Opravdu chcete odstranit tento kontakt?", "local_mode": "Kontakty jsou uloženy lokálně (server nepodporuje JMAP Contacts)", "back_to_contacts": "Zpět na kontakty", + "back_to_email": "Zpět na e-mail", "tabs": { "all": "Všechny", "groups": "Skupiny" diff --git a/locales/da/common.json b/locales/da/common.json index ab941962..66c1e56c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Er du sikker på, at du vil slette denne kontakt?", "local_mode": "Kontakter gemmes lokalt (serveren understøtter ikke JMAP-kontakter)", "back_to_contacts": "Tilbage til kontakter", + "back_to_email": "Tilbage til e-mail", "tabs": { "all": "Alle", "groups": "Grupper" diff --git a/locales/de/common.json b/locales/de/common.json index 4b0c168d..40a4f82e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1962,6 +1962,7 @@ "delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?", "local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)", "back_to_contacts": "Zurück zu Kontakten", + "back_to_email": "Zurück zur E-Mail", "tabs": { "all": "Alle", "groups": "Gruppen" diff --git a/locales/en/common.json b/locales/en/common.json index cd572e62..e1979b6d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1962,6 +1962,7 @@ "delete_confirm": "Are you sure you want to delete this contact?", "local_mode": "Contacts are stored locally (server does not support JMAP Contacts)", "back_to_contacts": "Back to contacts", + "back_to_email": "Back to email", "open_categories": "Open categories", "tabs": { "all": "All", diff --git a/locales/es/common.json b/locales/es/common.json index 0f530962..10d2aa31 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?", "local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)", "back_to_contacts": "Volver a contactos", + "back_to_email": "Volver al correo", "tabs": { "all": "Todos", "groups": "Grupos" diff --git a/locales/fr/common.json b/locales/fr/common.json index c7b50e76..1da607b9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?", "local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)", "back_to_contacts": "Retour aux contacts", + "back_to_email": "Retour à l'e-mail", "tabs": { "all": "Tous", "groups": "Groupes" diff --git a/locales/it/common.json b/locales/it/common.json index 19610f4b..847379cf 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Sei sicuro di voler eliminare questo contatto?", "local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)", "back_to_contacts": "Torna ai contatti", + "back_to_email": "Torna all'e-mail", "tabs": { "all": "Tutti", "groups": "Gruppi" diff --git a/locales/ja/common.json b/locales/ja/common.json index fa7d13b5..22427a57 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "この連絡先を削除してもよろしいですか?", "local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)", "back_to_contacts": "連絡先に戻る", + "back_to_email": "メールに戻る", "tabs": { "all": "すべて", "groups": "グループ" diff --git a/locales/ko/common.json b/locales/ko/common.json index 2a88993c..041c0760 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "정말 이 연락처를 삭제할까요?", "local_mode": "연락처가 로컬에 저장돼요 (서버가 JMAP Contacts를 지원하지 않아요)", "back_to_contacts": "연락처로 돌아가기", + "back_to_email": "이메일로 돌아가기", "tabs": { "all": "전체", "groups": "그룹" diff --git a/locales/lv/common.json b/locales/lv/common.json index adc2a089..d6cf53a4 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1946,6 +1946,7 @@ "delete_confirm": "Vai tiešām vēlaties dzēst šo kontaktu?", "local_mode": "Kontakti tiek glabāti lokāli (serveris neatbalsta JMAP Contacts)", "back_to_contacts": "Atpakaļ pie kontaktiem", + "back_to_email": "Atpakaļ pie e-pasta", "tabs": { "all": "Visi", "groups": "Grupas" diff --git a/locales/nl/common.json b/locales/nl/common.json index 8652cab5..72adf8e4 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?", "local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)", "back_to_contacts": "Terug naar contacten", + "back_to_email": "Terug naar e-mail", "tabs": { "all": "Alle", "groups": "Groepen" diff --git a/locales/pl/common.json b/locales/pl/common.json index 1fa5dd5c..25f56e20 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Czy na pewno chcesz usunąć ten kontakt?", "local_mode": "Kontakty są przechowywane lokalnie (serwer nie obsługuje JMAP Contacts)", "back_to_contacts": "Powrót do kontaktów", + "back_to_email": "Powrót do wiadomości", "tabs": { "all": "Wszystkie", "groups": "Grupy" diff --git a/locales/pt/common.json b/locales/pt/common.json index 0982fefd..c0566111 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Tem certeza de que deseja excluir este contato?", "local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)", "back_to_contacts": "Voltar aos contatos", + "back_to_email": "Voltar ao e-mail", "tabs": { "all": "Todos", "groups": "Grupos" diff --git a/locales/ru/common.json b/locales/ru/common.json index 4d199691..54687350 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Вы уверены, что хотите удалить этот контакт?", "local_mode": "Контакты хранятся локально (сервер не поддерживает JMAP Contacts)", "back_to_contacts": "Вернуться к контактам", + "back_to_email": "Вернуться к письму", "tabs": { "all": "Все", "groups": "Группы" diff --git a/locales/tr/common.json b/locales/tr/common.json index d05fd476..3c343cfa 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Bu kişiyi silmek istediğinizden emin misiniz?", "local_mode": "Kişiler yerel olarak saklanıyor (sunucu JMAP Kişilerini desteklemiyor)", "back_to_contacts": "Kişilere geri dön", + "back_to_email": "E-postaya geri dön", "tabs": { "all": "Tümü", "groups": "Gruplar" diff --git a/locales/uk/common.json b/locales/uk/common.json index d7601acd..7e182fe9 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "Ви впевнені, що хочете видалити цей контакт?", "local_mode": "Контакти зберігаються локально (сервер не підтримує контакти JMAP)", "back_to_contacts": "Назад до контактів", + "back_to_email": "Назад до листа", "tabs": { "all": "все", "groups": "Групи" diff --git a/locales/zh/common.json b/locales/zh/common.json index 0240363f..f18f7767 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1950,6 +1950,7 @@ "delete_confirm": "您确定要删除此联系人吗?", "local_mode": "联系人存储在本地(服务器不支持 JMAP 联系人)", "back_to_contacts": "返回联系人", + "back_to_email": "返回邮件", "tabs": { "all": "全部", "groups": "群组" From fc5f6f43d617a736002760191275ffd13200f592 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 23:35:58 +0200 Subject: [PATCH 32/44] feat: expose PWA, app identity, and extension directory keys in JSON config #312 --- app/(main)/[locale]/auth/callback/page.tsx | 2 +- app/(main)/[locale]/files/page.tsx | 4 +-- app/(main)/[locale]/login/page.tsx | 4 +-- app/(main)/[locale]/pro/page.tsx | 10 ++++---- app/(main)/admin/_tabs/auth.tsx | 4 +-- app/api/admin/config/route.ts | 2 +- app/api/admin/marketplace/[slug]/route.ts | 15 +++++++---- app/api/admin/marketplace/route.ts | 14 ++++++++--- app/api/auth/impersonate/route.ts | 6 ++--- app/api/auth/sso/complete/route.ts | 2 +- app/api/auth/sso/start/route.ts | 2 +- app/api/plugin-signing-pubkey/route.ts | 2 +- app/api/pwa-icon/[size]/route.ts | 7 +++++- app/api/setup/finish/route.ts | 2 +- app/manifest.ts | 25 ++++++++++++------- components/contacts/contacts-sidebar.tsx | 2 +- .../email/calendar-invitation-banner.tsx | 4 +-- components/email/email-composer.tsx | 6 ++--- components/email/email-viewer.tsx | 4 +-- components/files/file-browser.tsx | 4 +-- components/layout/navigation-rail.tsx | 2 +- components/layout/sidebar.tsx | 8 +++--- components/plugins/plugin-iframe-slot.tsx | 4 +-- components/pro/pro-compose-tab-body.tsx | 2 +- components/pro/pro-email-tab-body.tsx | 4 +-- components/pro/pro-interface-redirect.tsx | 2 +- .../providers/embedded-bridge-provider.tsx | 2 +- hooks/use-is-embedded.ts | 2 +- hooks/use-media-query.ts | 4 +-- hooks/use-pane-size.ts | 2 +- hooks/use-pro-multi-account-calendars.ts | 2 +- hooks/use-pro-multi-account-contacts.ts | 2 +- hooks/use-pro-multi-account-identities.ts | 4 +-- lib/__tests__/impersonation-jwt.test.ts | 2 +- lib/admin/plugin-approvals.ts | 2 +- lib/admin/plugin-dev.ts | 2 +- lib/admin/plugin-signing.ts | 2 +- lib/admin/types.ts | 6 +++++ lib/impersonation/jwt.ts | 12 ++++----- lib/impersonation/master-config.ts | 4 +-- lib/jmap/types.ts | 8 +++--- lib/plugin-loader.ts | 2 +- lib/plugin-sandbox/bundle-signing.ts | 2 +- lib/plugin-sandbox/host-api.ts | 4 +-- lib/plugin-sandbox/host-bridge.ts | 6 ++--- lib/plugin-sandbox/runtime.tsx | 4 +-- lib/setup/session.ts | 2 +- lib/vcard.ts | 16 ++++++------ lib/version-compare.ts | 2 +- stores/auth-store.ts | 4 +-- stores/calendar-store.ts | 14 +++++------ stores/client-registry.ts | 2 +- stores/email-store.ts | 6 ++--- stores/file-store.ts | 2 +- stores/plugin-store.ts | 8 +++--- stores/pro-tab-store.ts | 8 +++--- stores/settings-store.ts | 6 ++--- stores/smime-store.ts | 2 +- 58 files changed, 159 insertions(+), 130 deletions(-) diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index efe701cb..a469c194 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -90,7 +90,7 @@ function OAuthCallbackInner() { if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) { // Drive /api/auth/sso/complete directly so we can read the tokens - // out of the response — loginWithServerSso would consume them and + // out of the response - loginWithServerSso would consume them and // wire up the webmail auth store, which isn't useful here. The // server's mobile-flow branch (keyed on the pending cookie) skips // the refresh-token cookie write for the same reason. diff --git a/app/(main)/[locale]/files/page.tsx b/app/(main)/[locale]/files/page.tsx index 7b8d22fa..10aa3a52 100644 --- a/app/(main)/[locale]/files/page.tsx +++ b/app/(main)/[locale]/files/page.tsx @@ -136,7 +136,7 @@ export default function FilesPage() { // Initialize JMAP files client. In the Pro shell, all connected accounts // are surfaced as top-level folders at the root, so we *don't* auto-attach - // to the active account — the user picks one explicitly. + // to the active account - the user picks one explicitly. useEffect(() => { if (!isAuthenticated || !client || hasFetched.current) return; hasFetched.current = true; @@ -397,7 +397,7 @@ export default function FilesPage() { const currentFilesAccountId = useFileStore((s) => s.currentAccountId); // Pro shell only: all connected accounts are equal top-level entries at - // the root. The root path "/" itself is a cross-account picker — no + // the root. The root path "/" itself is a cross-account picker - no // account's files are shown until the user enters one. const accountFolders = isEmbedded ? accounts diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index a998af52..0ef6ccd7 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -351,7 +351,7 @@ export default function LoginPage() { const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; // In mobile-handoff mode the callback page needs to know it should // redirect into the app rather than into /mail. Stash the params in - // sessionStorage so the same-tab callback can read them — the SSO + // sessionStorage so the same-tab callback can read them - the SSO // pending cookie carries the authoritative copy server-side too. if (isMobileHandoff) { try { @@ -623,7 +623,7 @@ export default function LoginPage() { saveUsername(formData.username); if (isMobileHandoff) { // The isAuthenticated effect handles the redirect; nothing else to - // do here. Don't push to / — that would race the deep link. + // do here. Don't push to / - that would race the deep link. return; } router.push('/'); diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index c4c9b51e..c8b00f14 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -58,8 +58,8 @@ interface PaneProps { function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) { const paneRef = useRef(null); // Measured pane width, published to children via PaneSizeContext so that - // useDeviceDetection / useIsMobile / etc. branch on pane width — not full - // viewport — and inner pages collapse to their mobile/tablet layouts when + // useDeviceDetection / useIsMobile / etc. branch on pane width - not full + // viewport - and inner pages collapse to their mobile/tablet layouts when // the pane is narrow. const [paneWidth, setPaneWidth] = useState(null); @@ -268,7 +268,7 @@ export default function ProHome() { // Stable keys are essential: when the split collapses, the row's child // list goes from [splitPane, divider, mainPane] (or the leading variant) // to [mainPane]. Without keys, React would reuse the Pane instance at - // index 0 — repurposing the *split* pane's instance into the main pane, + // index 0 - repurposing the *split* pane's instance into the main pane, // which strands the main pane's ResizeObserver/paneWidth on a now- // unmounted DOM node and reparents the mail tab body (causing remount // + stale "still-narrow" measurements after the split is closed). @@ -316,7 +316,7 @@ export default function ProHome() {
- {/* Leftmost Navigation Rail — identical to the standard layout */} + {/* Leftmost Navigation Rail - identical to the standard layout */}
- {/* Panes container — accepts body drops for split/move. */} + {/* Panes container - accepts body drops for split/move. */}
- + diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index 7bc89aaa..cf89f033 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types'; import { parseJmapServers } from '@/lib/admin/jmap-servers'; import { logger } from '@/lib/logger'; -// Strings that count as "no real secret configured" — used so the dashboard +// Strings that count as "no real secret configured" - used so the dashboard // can warn about a placeholder session secret without us ever returning the // raw value to the client. const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']); diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index ddd003da..80f16422 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -7,8 +7,12 @@ import { } from '@/lib/admin/plugin-registry'; import JSZip from 'jszip'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { configManager } from '@/lib/admin/config-manager'; -const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org'; +async function getDirectoryUrl(): Promise { + await configManager.ensureLoaded(); + return configManager.get('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org'; +} const MAX_PREVIEW_SOURCE_LEN = 100_000; @@ -27,9 +31,10 @@ export async function GET( if ('error' in result) return result.error; const { slug } = await params; + const directoryUrl = await getDirectoryUrl(); // 1. Extension metadata + screenshots + theme previews from the directory - const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL); + const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl); const detailRes = await fetch(detailUrl.toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10000), @@ -63,7 +68,7 @@ export async function GET( try { const bundleUrl = new URL( `/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`, - DIRECTORY_URL, + directoryUrl, ); const bundleRes = await fetch(bundleUrl.toString(), { signal: AbortSignal.timeout(30000), @@ -151,7 +156,7 @@ export async function GET( // 4. Build screenshot URLs (proxy through the directory's public files endpoint). const screenshots = Array.isArray(extension.screenshots) ? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({ - url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(), + url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(), altText: s.altText ?? null, })) : []; @@ -170,7 +175,7 @@ export async function GET( const fileUrl = (path: unknown): string | null => typeof path === 'string' && path - ? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() + ? new URL(`/api/v1/files/${path}`, directoryUrl).toString() : null; return NextResponse.json( diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index b3720bad..907c37b7 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -19,8 +19,12 @@ import { import JSZip from 'jszip'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; +import { configManager } from '@/lib/admin/config-manager'; -const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org'; +async function getDirectoryUrl(): Promise { + await configManager.ensureLoaded(); + return configManager.get('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org'; +} /** * GET /api/admin/marketplace - Search/browse the extension directory @@ -31,8 +35,9 @@ export async function GET(request: NextRequest) { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + const directoryUrl = await getDirectoryUrl(); const { searchParams } = request.nextUrl; - const url = new URL('/api/v1/extensions', DIRECTORY_URL); + const url = new URL('/api/v1/extensions', directoryUrl); // Forward all search params for (const [key, value] of searchParams.entries()) { @@ -64,7 +69,7 @@ export async function GET(request: NextRequest) { const fileUrl = (path: unknown): string | null => typeof path === 'string' && path - ? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() + ? new URL(`/api/v1/files/${path}`, directoryUrl).toString() : null; if (data.data) { @@ -108,7 +113,8 @@ export async function POST(request: NextRequest) { } // Download the bundle from the directory - const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL); + const directoryUrl = await getDirectoryUrl(); + const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl); const bundleRes = await fetch(bundleUrl.toString(), { signal: AbortSignal.timeout(30000), }); diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts index 908704af..7a5dca04 100644 --- a/app/api/auth/impersonate/route.ts +++ b/app/api/auth/impersonate/route.ts @@ -23,7 +23,7 @@ const IMPERSONATION_SLOT = 0; /** * Impersonation cookies deliberately omit Max-Age so the browser treats - * them as session cookies — the impersonated session ends when the user + * them as session cookies - the impersonated session ends when the user * closes the browser, not 30 days later. Impersonation is a temporary * support handoff; a normal password login is the only thing that should * survive a browser restart. @@ -48,7 +48,7 @@ function impersonationCookieOptions() { export async function GET(request: NextRequest) { const config = readImpersonationConfig(); if (!config) { - // Not configured — behave exactly like an unknown route. + // Not configured - behave exactly like an unknown route. return new NextResponse('Not found', { status: 404 }); } @@ -112,7 +112,7 @@ export async function GET(request: NextRequest) { authHeader, }); - // Structured audit log — operators rely on this for security review. + // Structured audit log - operators rely on this for security review. logger.info('Impersonation session granted', { event: 'impersonation_granted', jti: claims.jti, diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index 4ca79151..06763cb6 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -74,7 +74,7 @@ export async function POST(request: NextRequest) { const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); // For the mobile handoff flow the tokens are handed back to the app - // verbatim — we deliberately don't write any cookies on the webmail + // verbatim - we deliberately don't write any cookies on the webmail // origin (the mobile browser tab disposes of the session after the // redirect anyway, but the cookie would still get committed to the // user's main webmail session if they happened to be logged in there). diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 59326c96..fd0e861f 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -77,7 +77,7 @@ export async function POST(request: NextRequest) { // /complete handler reaches the same OAuth endpoint we used to authorize. // Mobile params are captured here so /complete knows to return tokens to // the caller (in the JSON response) instead of writing the usual server - // cookies — and so the callback page can redirect back to the app. + // cookies - and so the callback page can redirect back to the app. const pendingData = { state, code_verifier: codeVerifier, diff --git a/app/api/plugin-signing-pubkey/route.ts b/app/api/plugin-signing-pubkey/route.ts index 8c49739e..7aba09d1 100644 --- a/app/api/plugin-signing-pubkey/route.ts +++ b/app/api/plugin-signing-pubkey/route.ts @@ -7,7 +7,7 @@ import { logger } from '@/lib/logger'; * * Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the * sandboxed plugin loader can verify bundle signatures before evaluation. - * Public — every logged-in user needs to fetch it on app boot. + * Public - every logged-in user needs to fetch it on app boot. * * The response is long-cache-eligible (the key rotates only when an operator * deletes the on-disk PEM), but we keep it `no-store` for simplicity. The diff --git a/app/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts index 8b42dbb7..14977870 100644 --- a/app/api/pwa-icon/[size]/route.ts +++ b/app/api/pwa-icon/[size]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import sharp from 'sharp'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; +import { configManager } from '@/lib/admin/config-manager'; const VALID_SIZES = new Set([192, 512]); @@ -32,7 +33,11 @@ export async function GET( return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 }); } - const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL; + await configManager.ensureLoaded(); + const sources = configManager.getAllWithSources(); + const iconUrl = + (sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') || + (sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : ''); if (!iconUrl) { return new NextResponse('No PWA icon configured', { status: 404 }); } diff --git a/app/api/setup/finish/route.ts b/app/api/setup/finish/route.ts index 381cebbe..b4d8ea50 100644 --- a/app/api/setup/finish/route.ts +++ b/app/api/setup/finish/route.ts @@ -60,7 +60,7 @@ export async function POST(request: NextRequest) { try { // 1. Provision the admin account. An admin.json file may already exist // from a previous ADMIN_PASSWORD env var or an aborted earlier wizard - // run while setupComplete is still false — accept the wizard's + // run while setupComplete is still false - accept the wizard's // password as authoritative in that case. The finish route is gated // by the bootstrap state + one-time setup token, so this is safe. const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true }); diff --git a/app/manifest.ts b/app/manifest.ts index 6d2d065c..16c86881 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -1,4 +1,5 @@ import type { MetadataRoute } from "next"; +import { configManager } from "@/lib/admin/config-manager"; export const dynamic = "force-dynamic"; @@ -21,22 +22,28 @@ type ExtendedManifest = MetadataRoute.Manifest & { const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); const withBase = (p: string) => `${BASE_PATH}${p}`; -export default function manifest(): ExtendedManifest { +export default async function manifest(): Promise { + await configManager.ensureLoaded(); + const appName = - process.env.APP_NAME || + configManager.get("appName") || process.env.NEXT_PUBLIC_APP_NAME || "Bulwark Webmail"; - const shortName = process.env.APP_SHORT_NAME || appName; + const shortName = configManager.get("appShortName") || appName; const description = - process.env.APP_DESCRIPTION || + configManager.get("appDescription") || "A modern webmail client built for Stalwart Mail Server"; - const themeColor = process.env.PWA_THEME_COLOR || "#ffffff"; - const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff"; + const themeColor = configManager.get("pwaThemeColor") || "#ffffff"; + const backgroundColor = configManager.get("pwaBackgroundColor") || "#ffffff"; - // If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs - // via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs. - const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL); + // If pwaIconUrl or faviconUrl was explicitly configured (admin override or + // env var), serve dynamically resized PNGs via /api/pwa-icon/[size]. + // Otherwise fall back to the static Bulwark PNGs - sources marked "default" + // are the built-in placeholder paths and not real custom icons. + const sources = configManager.getAllWithSources(); + const hasCustomIcon = + sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default"; const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon ? [ diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 2403b33d..99d4ce26 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -541,7 +541,7 @@ export function ContactsSidebar({ )}
- {/* Shared accounts with address books — only when not already split + {/* Shared accounts with address books - only when not already split into per-account groups above (multi-account Pro mode). */} {!multiAccountMode && sharedBookGroups.map((group) => (
diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index b87a42ac..17cc4fde 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -389,7 +389,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setActionError(null); try { // JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST - // is lost. Fetch raw ICS to extract METHOD as a reliable fallback — in + // is lost. Fetch raw ICS to extract METHOD as a reliable fallback - in // parallel with parsing to save a roundtrip. const [events, rawText] = await Promise.all([ client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId), @@ -420,7 +420,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setState('parsed'); - // Hydrate the calendar store with the matching event in the background — + // Hydrate the calendar store with the matching event in the background - // only needed for the "already in calendar" pill, must not block the banner. // Filter by UID server-side; the previous unfiltered query fetched up to // 1000 events plus multiple /get batches just to find one match. diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d13f3ddf..98b89989 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -982,7 +982,7 @@ export function EmailComposer({ try { const previousDraftId = draftIdRef.current; // Use the JMAP client and raw identity id for the *owning* account - // — falls back to active client for single-account / same-account + // - falls back to active client for single-account / same-account // identities. See `composerClient` derivation above. const savedDraftId = await composerClient.createDraft( toAddresses, @@ -1287,7 +1287,7 @@ export function EmailComposer({ // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { - // S/MIME keys are scoped to one JMAP account's identity — sending + // S/MIME keys are scoped to one JMAP account's identity - sending // from a cross-account identity via S/MIME would mix accounts' // certs/clients. Refuse upfront and tell the user to switch. const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id); @@ -1440,7 +1440,7 @@ export function EmailComposer({ const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput); // Strip the cross-account namespace from the identity id before - // handing it to the parent — the JMAP server only knows the raw + // handing it to the parent - the JMAP server only knows the raw // id. The owning local account travels alongside so the parent // can route the send through the right client. const rawIdentityId = outgoing.identityId || currentIdentity?.id; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 411468a6..5cb2d7bd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1142,7 +1142,7 @@ export function EmailViewer({ const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => { if (isMobileDevice) { - // No room for a sidebar on mobile — send the user to the contacts page + // No room for a sidebar on mobile - send the user to the contacts page // with params describing what to show. The `from=email` flag turns the // page's mobile back button into a router.back() that returns here. const allRecipients = [ @@ -2896,7 +2896,7 @@ export function EmailViewer({ // window between selectedEmail changing and isLoading flipping true, so the // quick reply / body don't flicker through a partial render. // An empty bodyValues with no referenced parts means the email has no body - // (e.g. calendar-only invites) — not "still loading". + // (e.g. calendar-only invites) - not "still loading". const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0; const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0)); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 926f95f5..8399a612 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -91,7 +91,7 @@ interface FileBrowserProps { /** Pro shell only: all connected accounts surfaced as top-level folders at the root. */ accountFolders?: AccountFolderEntry[]; onSelectAccount?: (accountId: string) => void; - /** Pro shell only: when true, the root is a pure account picker — hide the file toolbar and don't render a regular listing. */ + /** Pro shell only: when true, the root is a pure account picker - hide the file toolbar and don't render a regular listing. */ accountPickerMode?: boolean; /** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */ accountLabel?: string | null; @@ -471,7 +471,7 @@ export function FileBrowser({ }, [resources, searchQuery, sortKey, sortDir, folderLayout]); // Build breadcrumb segments. In Pro mode an account is mounted "between" - // Home and the account's filesystem — surfaced as a non-clickable label + // Home and the account's filesystem - surfaced as a non-clickable label // (clicking the actual account again would be a no-op; Home detaches it). const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = currentPath === '/' ? [{ name: t("breadcrumb_root"), path: '/' }] diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 1d1b05e2..8a8b58f9 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -47,7 +47,7 @@ interface NavigationRailProps { activeAppId?: string | null; /** * If provided, intercepts the rail's built-in route navigation. Return - * `true` to prevent the underlying `` from navigating — used by the + * `true` to prevent the underlying `` from navigating - used by the * Pro interface to open the route as a tab instead. The visual rail is * unchanged. */ diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index f397a9ae..a67ccab2 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -78,7 +78,7 @@ interface SidebarProps { /** * Multi-account (Pro) mode props. When `multiAccountMode` is true, the * sidebar renders a per-connected-account group instead of a single - * folders section — Thunderbird-style. `accountMailboxes` provides the + * folders section - Thunderbird-style. `accountMailboxes` provides the * mailbox list for non-active accounts (the active account still flows * through the `mailboxes` prop). `viewingAccountId` highlights which * account's folder is currently selected (null = active account). @@ -715,7 +715,7 @@ export function Sidebar({ } catch { return new Set(); } }); // Per-connected-account collapse state for Pro / Thunderbird-style mode. - // Stored as the set of accountIds the user has explicitly collapsed — + // Stored as the set of accountIds the user has explicitly collapsed - // anything not in the set is treated as expanded. Inverting the storage // model lets new accounts default to expanded automatically. const [collapsedAccountGroups, setCollapsedAccountGroups] = useState>(() => { @@ -738,7 +738,7 @@ export function Sidebar({ const connectedAccounts = accounts.filter(a => a.isConnected); // Pro shell treats the unified mailbox as a core part of the multi-account // UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The - // 2+ account requirement still applies — with a single account the + // 2+ account requirement still applies - with a single account the // unified counts would just duplicate that account's inbox. const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1; const { unifiedCounts } = useEmailStore(); @@ -930,7 +930,7 @@ export function Sidebar({ className )} > - {/* Header — hidden in the Pro shell, which owns its own chrome and + {/* Header - hidden in the Pro shell, which owns its own chrome and would otherwise render an empty strip (no collapse, no switcher). */} {!isEmbedded && (
diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index b95f3166..5bbdf2e8 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -1,6 +1,6 @@ 'use client'; -// Sandboxed slot mount. One iframe per (plugin, slot) — created lazily after +// Sandboxed slot mount. One iframe per (plugin, slot) - created lazily after // the background instance confirms `shouldShow(context)` (if defined). The // iframe renders the plugin's slot component using the plugin's bundle in a // null-origin context; its height is pushed back via postMessage and applied @@ -59,7 +59,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { try { inst.destroy(); } catch { /* ignore */ } instanceRef.current = null; }; - // We intentionally don't depend on extraProps here — propagating prop + // We intentionally don't depend on extraProps here - propagating prop // changes happens via postMessage below to avoid iframe churn. // eslint-disable-next-line react-hooks/exhaustive-deps }, [show, pluginId, slot]); diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index ceea8b4a..5f24f526 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -18,7 +18,7 @@ interface ProComposeTabBodyProps { /** * Renders a standalone `` inside its own Pro tab. Sending, * draft autosave, and discard all flow through the shared `email-store`, so - * the result is identical to composing inline in the mail page — the + * the result is identical to composing inline in the mail page - the * composer is just hosted in its own tab instead of in the right pane. */ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 55e43e10..0f5fc0d7 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -39,7 +39,7 @@ function buildReplyContext(email: Email): ProReplyContext { /** * Renders a single email in its own Pro tab. Fetches the email content on - * mount via `email-store.fetchEmailContent` so the tab is self-sufficient — + * mount via `email-store.fetchEmailContent` so the tab is self-sufficient - * it doesn't depend on what the Mail tab has selected. */ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { @@ -160,7 +160,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { if (!client || !email) return; try { await toggleStar(client, email.id); - // Reflect locally — the viewer re-reads from email-store's selectedEmail + // Reflect locally - the viewer re-reads from email-store's selectedEmail // shape only for the mail tab; here we update our local copy too. setEmail((prev) => prev ? { ...prev, diff --git a/components/pro/pro-interface-redirect.tsx b/components/pro/pro-interface-redirect.tsx index 098fe219..132856fe 100644 --- a/components/pro/pro-interface-redirect.tsx +++ b/components/pro/pro-interface-redirect.tsx @@ -16,7 +16,7 @@ const STANDARD_PATH_TO_TAB: Record { if (!embeddedMode || !isEmbedded()) return; - // Refuse to attach the listener without a pinned parent origin — + // Refuse to attach the listener without a pinned parent origin - // otherwise any cross-origin frame could forge sso:trigger-logout. if (!parentOrigin) { console.error( diff --git a/hooks/use-is-embedded.ts b/hooks/use-is-embedded.ts index 3ef8acbf..3c16d98b 100644 --- a/hooks/use-is-embedded.ts +++ b/hooks/use-is-embedded.ts @@ -8,7 +8,7 @@ import { createContext, useContext } from "react"; * read this to hide their own NavigationRail and let the shell own the * chrome. * - * Provided via context by the Pro shell — no URL coupling, no iframe. + * Provided via context by the Pro shell - no URL coupling, no iframe. */ export const EmbeddedContext = createContext(false); diff --git a/hooks/use-media-query.ts b/hooks/use-media-query.ts index bcec359a..16465b74 100644 --- a/hooks/use-media-query.ts +++ b/hooks/use-media-query.ts @@ -42,7 +42,7 @@ export function useMediaQuery(query: string): boolean { /** * When the Pro shell renders a page inside a (possibly split) pane, that pane * publishes its measured width via `PaneSizeContext`. Inner pages should - * branch their layout against the pane width — not the full viewport — so a + * branch their layout against the pane width - not the full viewport - so a * narrow pane gets the mobile/tablet layout instead of overflowing. * * Returns `null` when no pane size is published, signalling the caller to @@ -63,7 +63,7 @@ function classifyPane(paneWidth: number | null) { * * When invoked inside a Pro pane, the returned values reflect the pane's * width instead of the window's. The global UI store is NOT updated in that - * case — two split panes would otherwise fight to write conflicting values, + * case - two split panes would otherwise fight to write conflicting values, * and the store is meant to mirror the actual viewport for callers that read * it directly (mobile navigation helpers etc.). */ diff --git a/hooks/use-pane-size.ts b/hooks/use-pane-size.ts index 1b952073..e0fdd0f5 100644 --- a/hooks/use-pane-size.ts +++ b/hooks/use-pane-size.ts @@ -4,7 +4,7 @@ import { createContext, useContext } from "react"; /** * Width of the pane that's hosting the current subtree, in CSS pixels. - * `null` means "no pane is providing a size" — fall back to viewport-based + * `null` means "no pane is providing a size" - fall back to viewport-based * media queries. Set by the Pro shell on each split pane via ResizeObserver. */ export const PaneSizeContext = createContext(null); diff --git a/hooks/use-pro-multi-account-calendars.ts b/hooks/use-pro-multi-account-calendars.ts index 5db46ede..7bc225ad 100644 --- a/hooks/use-pro-multi-account-calendars.ts +++ b/hooks/use-pro-multi-account-calendars.ts @@ -9,7 +9,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded"; /** * When the Pro shell is the active interface, aggregate calendars from - * every connected account so the calendar sidebar lists them all — the + * every connected account so the calendar sidebar lists them all - the * same way [[use-pro-multi-account-mailboxes]] does for mail folders. * * Returns the resolved list of `{ localAccountId, client }` pairs so the diff --git a/hooks/use-pro-multi-account-contacts.ts b/hooks/use-pro-multi-account-contacts.ts index 8328f4e9..a69b7c0d 100644 --- a/hooks/use-pro-multi-account-contacts.ts +++ b/hooks/use-pro-multi-account-contacts.ts @@ -8,7 +8,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; /** - * Pro-shell counterpart to [[useProMultiAccountCalendars]] — aggregates + * Pro-shell counterpart to [[useProMultiAccountCalendars]] - aggregates * contacts and address books from every connected JMAP account so the * contacts sidebar lists them all, grouped by local account. */ diff --git a/hooks/use-pro-multi-account-identities.ts b/hooks/use-pro-multi-account-identities.ts index ddff1c2c..786b7796 100644 --- a/hooks/use-pro-multi-account-identities.ts +++ b/hooks/use-pro-multi-account-identities.ts @@ -37,7 +37,7 @@ export function stripCrossAccountIdentityPrefix(id: string): { localAccountId: s /** * Pro shell only: load identities from every connected account and group * them by local account so the composer's From dropdown can render an - * per account — mirrors [[useProMultiAccountCalendars]] and + * per account - mirrors [[useProMultiAccountCalendars]] and * [[useProMultiAccountContacts]]. * * Outside Pro / embedded mode the hook returns `enabled: false` and the @@ -82,7 +82,7 @@ export function useProMultiAccountIdentities(): { const list = await client.getIdentities(); if (!cancelled) next[account.id] = list; } catch { - // Skip accounts that fail to load identities — one bad + // Skip accounts that fail to load identities - one bad // account shouldn't blank the whole dropdown. } }), diff --git a/lib/__tests__/impersonation-jwt.test.ts b/lib/__tests__/impersonation-jwt.test.ts index 9bba40fd..7b2ef8be 100644 --- a/lib/__tests__/impersonation-jwt.test.ts +++ b/lib/__tests__/impersonation-jwt.test.ts @@ -120,7 +120,7 @@ describe('impersonationReplayCache', () => { it('prunes expired jtis on next consume', () => { const now = Math.floor(Date.now() / 1000); impersonationReplayCache.consume('jti-old', now - 600, now - 600); - // Far in the future — pruning should clear the old entry. + // Far in the future - pruning should clear the old entry. expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true); // Re-using the old jti is allowed after pruning (security irrelevant since // the token would fail signature/exp validation upstream). diff --git a/lib/admin/plugin-approvals.ts b/lib/admin/plugin-approvals.ts index 98e736b3..bed83315 100644 --- a/lib/admin/plugin-approvals.ts +++ b/lib/admin/plugin-approvals.ts @@ -7,7 +7,7 @@ // run. // // Each entry has one of three states: 'pending' (user installed, waiting for -// admin), 'approved' (admin signed off), 'denied' (admin refused — kept so we +// admin), 'approved' (admin signed off), 'denied' (admin refused - kept so we // don't keep asking). import { readFile, writeFile, rename } from 'node:fs/promises'; diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 079182ef..84863a5c 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -155,7 +155,7 @@ async function loadDevPlugin(pluginDir: string): Promise // Hash from the exact bytes the bundle endpoint will serve so the client's // verifyBundle check passes. For src/ sources that means running esbuild - // here too — slightly more work per manifest list, but unavoidable since + // here too - slightly more work per manifest list, but unavoidable since // the source hash wouldn't match the served bundle. let bundleHash: string; try { diff --git a/lib/admin/plugin-signing.ts b/lib/admin/plugin-signing.ts index 9f5f2ff7..cf0ff247 100644 --- a/lib/admin/plugin-signing.ts +++ b/lib/admin/plugin-signing.ts @@ -8,7 +8,7 @@ // The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded // PKCS#8 private, mode 0600) and is generated lazily on first use. Operators // who want to pin the key out-of-band can drop a pre-generated PEM at that -// path before first boot — the loader just reads what's there. +// path before first boot - the loader just reads what's there. import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto'; import { readFile, writeFile, chmod } from 'node:fs/promises'; diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 98f994a8..320c783b 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -128,11 +128,16 @@ export interface AuditEntry { /** Config keys that map to environment variables */ export const CONFIG_ENV_MAP: Record = { appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' }, + appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' }, + appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' }, jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' }, stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true }, demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false }, devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false }, faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' }, + pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' }, + pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' }, + pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' }, appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' }, appLogoDarkUrl: { envVar: 'APP_LOGO_DARK_URL', type: 'url', defaultValue: '' }, loginLogoLightUrl: { envVar: 'LOGIN_LOGO_LIGHT_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_Color.svg' }, @@ -159,6 +164,7 @@ export const CONFIG_ENV_MAP: Record; if (header.alg !== 'HS256') { throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`); @@ -91,7 +91,7 @@ export function verifyImpersonationJwt( throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`); } - // Signature — constant-time compare. + // Signature - constant-time compare. const expected = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest(); const provided = base64UrlDecode(sigB64); if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { @@ -109,7 +109,7 @@ export function verifyImpersonationJwt( const jti = assertString(payload.jti, 'jti'); const mailbox = assertString(payload.mailbox, 'mailbox'); - // Mailbox MUST NOT contain '%' or ':' — those would inject into the + // Mailbox MUST NOT contain '%' or ':' - those would inject into the // master-user auth header. if (mailbox.includes('%') || mailbox.includes(':')) { throw new ImpersonationJwtError('mailbox', "mailbox must not contain '%' or ':'"); @@ -126,7 +126,7 @@ export function verifyImpersonationJwt( if (iat - CLOCK_SKEW_SEC > nowSec) { throw new ImpersonationJwtError('iat', 'Token issued in the future'); } - // Hard ceiling on lifetime — refuse long-lived handoff tokens even if the + // Hard ceiling on lifetime - refuse long-lived handoff tokens even if the // signer asked for one. if (exp - iat > MAX_TOKEN_LIFETIME_SEC) { throw new ImpersonationJwtError('lifetime', `Token lifetime exceeds ${MAX_TOKEN_LIFETIME_SEC}s ceiling`); @@ -158,7 +158,7 @@ class ReplayCache { this.prune(now); if (this.entries.has(jti)) return false; if (this.entries.size >= REPLAY_CACHE_MAX) { - // Evict the oldest entry — Map preserves insertion order. + // Evict the oldest entry - Map preserves insertion order. const first = this.entries.keys().next().value; if (first !== undefined) this.entries.delete(first); } @@ -171,7 +171,7 @@ class ReplayCache { if (exp + CLOCK_SKEW_SEC < now) { this.entries.delete(jti); } else { - // Insertion order means later entries are no older than this one — but + // Insertion order means later entries are no older than this one - but // exp isn't strictly monotonic with insertion, so we can't break here. } } diff --git a/lib/impersonation/master-config.ts b/lib/impersonation/master-config.ts index 19928a35..392d3d15 100644 --- a/lib/impersonation/master-config.ts +++ b/lib/impersonation/master-config.ts @@ -8,7 +8,7 @@ export interface ImpersonationConfig { } /** - * Returns null when impersonation is not configured — the route MUST surface + * Returns null when impersonation is not configured - the route MUST surface * that as a 404 so an unconfigured deployment doesn't expose the endpoint. * * Required env: @@ -38,7 +38,7 @@ export function readImpersonationConfig(): ImpersonationConfig | null { * legacy env fallbacks. Returns null if none is configured. * * The impersonation flow is server-to-server (no user input), so we never - * accept a custom endpoint — only admin-configured URLs. + * accept a custom endpoint - only admin-configured URLs. */ export async function resolveImpersonationServerUrl(): Promise { await configManager.ensureLoaded(); diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 17141ef8..517f678d 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -159,7 +159,7 @@ export interface Identity { textSignature?: string; htmlSignature?: string; mayDelete: boolean; - // See `Calendar.localAccountId` — set when the Pro shell aggregates + // See `Calendar.localAccountId` - set when the Pro shell aggregates // identities from multiple connected accounts so we can route sends // back through the owning JMAP client. `accountName` is the // user-facing label for the dropdown's optgroup. @@ -178,7 +178,7 @@ export interface ContactCard { accountId?: string; accountName?: string; isShared?: boolean; - // Local account-store ID — set when the Pro shell aggregates contacts + // Local account-store ID - set when the Pro shell aggregates contacts // from multiple connected accounts. See `Calendar.localAccountId`. localAccountId?: string; language?: string; @@ -381,7 +381,7 @@ export interface AddressBook { accountId?: string; accountName?: string; isShared?: boolean; - // See `Calendar.localAccountId` — same purpose for address books. + // See `Calendar.localAccountId` - same purpose for address books. localAccountId?: string; } @@ -483,7 +483,7 @@ export interface CalendarEvent { accountId?: string; accountName?: string; isShared?: boolean; - // See `Calendar.localAccountId` — same purpose for events. + // See `Calendar.localAccountId` - same purpose for events. localAccountId?: string; isDraft: boolean; isOrigin: boolean; diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index 468af2fa..b388a41f 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -19,7 +19,7 @@ import { all as allActive, get as getActive } from './plugin-sandbox/registry'; * Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__` * so blob-imported plugin code could resolve `react`. With the sandbox model * plugins receive React injected as a function argument inside their iframe - * runtime — there is nothing to expose on the host window. + * runtime - there is nothing to expose on the host window. * * Kept as a no-op for callers that still invoke it during app bootstrap. */ diff --git a/lib/plugin-sandbox/bundle-signing.ts b/lib/plugin-sandbox/bundle-signing.ts index 1298789c..451edc42 100644 --- a/lib/plugin-sandbox/bundle-signing.ts +++ b/lib/plugin-sandbox/bundle-signing.ts @@ -6,7 +6,7 @@ // a bundle the loader verifies the signature; mismatch refuses the load. // // User-installed plugins (uploaded via the file picker, no server hop) have -// no signature — verification is skipped for those, since the user is +// no signature - verification is skipped for those, since the user is // installing their own code. Verification kicks in for server-managed // bundles only (the `managed: true` flag on `InstalledPlugin`). diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 29626bb1..856650d0 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -28,7 +28,7 @@ const PERM_PER_METHOD: Record = { 'admin.getAllConfig': 'admin:config', 'admin.setConfig': 'admin:config', 'admin.deleteConfig': 'admin:config', - // ui — any plugin can ask the host to render a modal or open a URL. + // ui - any plugin can ask the host to render a modal or open a URL. 'ui.confirm': null, 'ui.alert': null, 'ui.openExternalUrl': null, @@ -289,7 +289,7 @@ export async function dispatchApiCall( } case 'ui.openExternalUrl': { const url = String(args[0] ?? ''); - // Only http(s) — the sandbox should not be able to navigate the host + // Only http(s) - the sandbox should not be able to navigate the host // anywhere internal, nor open javascript:/data:/file: schemes. let parsed: URL; try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); } diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index dd41be52..90b0c6df 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -39,7 +39,7 @@ function encodeCallbacks( if (Array.isArray(value)) { return value.map((v) => encodeCallbacks(v, table, depth + 1)); } - // Plain object — copy own enumerable keys. + // Plain object - copy own enumerable keys. const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = encodeCallbacks(v, table, depth + 1); @@ -161,7 +161,7 @@ export class SandboxInstance { private send(msg: HostToSandbox): void { // targetOrigin '*' is required because the iframe is opaque-origin. The - // payload contains no host secrets — bundle code and manifest fields the + // payload contains no host secrets - bundle code and manifest fields the // plugin already owns. this.iframe.contentWindow?.postMessage(msg, '*'); } @@ -236,7 +236,7 @@ export class SandboxInstance { } case 'slot-resize': - // The iframe has no intrinsic height — sync it to the content height + // The iframe has no intrinsic height - sync it to the content height // the sandbox reported, otherwise the wrapper reserves space but the // iframe stays at 0px and the slot appears blank. this.iframe.style.height = `${msg.height}px`; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 8adf78bc..e171875e 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -181,7 +181,7 @@ function buildPluginApi(manifest: PluginManifest) { /** * Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin * bundlers should be configured to externalise React; the runtime provides - * those modules here. Anything else is refused — the sandbox has no Node- + * those modules here. Anything else is refused - the sandbox has no Node- * compatible module resolution and we don't want plugins probing globals. * * The host injects the per-plugin API as `@plugin-host`, so plugin code can @@ -337,7 +337,7 @@ function bootSlot(payload: SlotInit): void { sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] }); } -// Populated by bootSlot — receives `props-update` messages. +// Populated by bootSlot - receives `props-update` messages. let slotPropsUpdater: ((next: Record) => void) | null = null; async function handleInit(payload: InitPayload): Promise { diff --git a/lib/setup/session.ts b/lib/setup/session.ts index a3ee7368..46938d58 100644 --- a/lib/setup/session.ts +++ b/lib/setup/session.ts @@ -25,7 +25,7 @@ export async function authenticateWizardRequest(): Promise { export function buildSessionCookieAttributes(request?: NextRequest) { // Match Secure to the actual request protocol. Browsers drop Secure cookies // on plain HTTP, so unconditionally setting Secure in production breaks - // setup over HTTP — the operator gets "Wizard session required" on every + // setup over HTTP - the operator gets "Wizard session required" on every // step. The wizard surfaces a cleartext-credentials warning in the UI when // HTTPS isn't in use. return { diff --git a/lib/vcard.ts b/lib/vcard.ts index efaea176..efd592d4 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -57,7 +57,7 @@ function unfoldLines(vcf: string): string { .replace(/\n[ \t]/g, ""); } -// RFC 6868 parameter value encoding — used inside parameter values only. +// RFC 6868 parameter value encoding - used inside parameter values only. // Caret-encoded sequences: ^n → LF, ^^ → ^, ^' → DQUOTE. function decodeParamValue(s: string): string { let out = ""; @@ -301,7 +301,7 @@ export function parseVCard(vcfString: string): ContactCard[] { function buildContact(raw: Record): ContactCard | null { const id = `import-${generateUUID()}`; const card: ContactCard = { id, addressBookIds: {} }; - // Deferred BIRTHPLACE/DEATHPLACE values — attach to anniversary at end, + // Deferred BIRTHPLACE/DEATHPLACE values - attach to anniversary at end, // because the BDAY/DEATHDATE entry may appear in any order. let birthPlace: string | undefined; let deathPlace: string | undefined; @@ -465,7 +465,7 @@ function buildContact(raw: Record): ContactCard | null { mediaType: mime, }; } else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) { - // vCard 4.0 URI value (data URI or URL) — no ENCODING param. + // vCard 4.0 URI value (data URI or URL) - no ENCODING param. card.media[`m${idx}`] = { kind: "photo", uri: val, @@ -760,7 +760,7 @@ function buildContact(raw: Record): ContactCard | null { } case "ORG-DIRECTORY": { - // RFC 6715 §2.4 — directory URI for the contact's organization. + // RFC 6715 §2.4 - directory URI for the contact's organization. if (!card.directories) card.directories = {}; const idx = Object.keys(card.directories).length; card.directories[`d${idx}`] = { @@ -789,14 +789,14 @@ function buildContact(raw: Record): ContactCard | null { break; case "GRAMGENDER": { - // RFC 9554 §3.4 — grammatical gender (animate/common/feminine/masculine/neuter). + // RFC 9554 §3.4 - grammatical gender (animate/common/feminine/masculine/neuter). if (!card.speakToAs) card.speakToAs = {}; card.speakToAs.grammaticalGender = val.toLowerCase(); break; } case "PRONOUNS": { - // RFC 9554 §3.5 — free-form pronouns. May appear multiple times. + // RFC 9554 §3.5 - free-form pronouns. May appear multiple times. if (!card.speakToAs) card.speakToAs = {}; if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {}; const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`; @@ -1058,7 +1058,7 @@ function generateSingleVCard(contact: ContactCard): string { } if (contact.personalInfo) { - // RFC 6715 — emit EXPERTISE / HOBBY / INTEREST with LEVEL. + // RFC 6715 - emit EXPERTISE / HOBBY / INTEREST with LEVEL. const levelOut: Record> = { expertise: { high: "expert", medium: "average", low: "beginner" }, hobby: { high: "high", medium: "medium", low: "low" }, @@ -1167,7 +1167,7 @@ function generateSingleVCard(contact: ContactCard): string { } if (contact.created) { - // RFC 9554 §3.1 — CREATED is a timestamp; emit as-is for round-trip. + // RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip. lines.push(`CREATED:${contact.created}`); } diff --git a/lib/version-compare.ts b/lib/version-compare.ts index 560fd99b..c8e3c133 100644 --- a/lib/version-compare.ts +++ b/lib/version-compare.ts @@ -1,7 +1,7 @@ /** * Lenient semver comparison for the marketplace's `minAppVersion` gate. * - * Parses "major.minor.patch" (any segment may be missing — treated as 0) + * Parses "major.minor.patch" (any segment may be missing - treated as 0) * and ignores pre-release / build metadata. Returns negative, zero or * positive in the same shape as Array.prototype.sort comparators. * diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9e5647d5..5b7fcd9c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1256,7 +1256,7 @@ export const useAuthStore = create()( return; } - // Orphan-cookie adoption — when no accounts are registered but a + // Orphan-cookie adoption - when no accounts are registered but a // basic-auth session cookie is present (set by /api/auth/impersonate // or by another server-side hand-off), promote it into the account // registry so the normal restoration path picks it up. Without this @@ -1664,5 +1664,5 @@ export const useAuthStore = create()( ); // Expose getClientForAccount to the calendar/contact stores via a small -// shared registry — see [[stores/client-registry]] for rationale. +// shared registry - see [[stores/client-registry]] for rationale. setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId)); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 20db15e3..1fb94a40 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -15,7 +15,7 @@ import { getClientByLocalAccountId } from './client-registry'; /** * When the Pro shell aggregates calendars/events from every connected * account, the entity carries a `localAccountId` pointing back to the - * owning JMAP client. Mutations need to use *that* client — the active + * owning JMAP client. Mutations need to use *that* client - the active * client (passed in by the page) could be on a different server entirely. * Falls back to the active client when `localAccountId` is unset or no * matching client is registered. @@ -75,7 +75,7 @@ function prefixCalendarsWithLocalAccount( return calendars.map((cal) => ({ ...cal, localAccountId })); } const prefix = buildCrossAccountIdPrefix(localAccountId); - // Preserve each calendar's original `isShared` flag — it distinguishes + // Preserve each calendar's original `isShared` flag - it distinguishes // the user's own calendars on the other account from calendars shared // *into* that account by yet another user. The sidebar uses this split // to render "My Calendars" vs "Shared" sub-sections per account. @@ -186,7 +186,7 @@ export interface ICalSubscription { url: string; calendarId: string; // The JMAP account this subscription belongs to. Optional for back- - // compat with subs persisted before multi-account scoping landed — + // compat with subs persisted before multi-account scoping landed - // legacy entries with no accountId are shown only in whichever account // the user has active (treated as floating). New subs always set it. accountId?: string; @@ -910,7 +910,7 @@ export const useCalendarStore = create()( if (calendarEvents.length === 0) break; // Separate events that live ONLY in this calendar (delete) from - // events also linked to other calendars (unlink only — don't + // events also linked to other calendars (unlink only - don't // cascade-delete the user's copy elsewhere). const idsToDelete: string[] = []; const eventsToUnlink: Array<{ id: string; calendarIds: Record }> = []; @@ -1008,7 +1008,7 @@ export const useCalendarStore = create()( icalSubscriptions: [...state.icalSubscriptions, subscription], })); - // Initial fetch — roll back the calendar create if it fails so we + // Initial fetch - roll back the calendar create if it fails so we // don't leave a phantom calendar around after a bad URL / 404 / etc. await get().refreshICalSubscription(client, subscription.id); @@ -1097,7 +1097,7 @@ export const useCalendarStore = create()( if (!sub) return; // Skip if the subscription is scoped to a different JMAP account - // than the one this client is talking to — otherwise we'd create + // than the one this client is talking to - otherwise we'd create // events in the wrong account / against a missing calendar. if (sub.accountId && sub.accountId !== client.getAccountId()) { debug.warn('calendar', 'Skipping subscription refresh: account mismatch', { sub: sub.name }); @@ -1228,7 +1228,7 @@ export const useCalendarStore = create()( clearState: () => { // Preserve iCal subscriptions across the account-switch teardown. - // They're now scoped per-account via sub.accountId — wiping them + // They're now scoped per-account via sub.accountId - wiping them // here would lose them from localStorage on every switch. const preservedSubs = get().icalSubscriptions; set({ diff --git a/stores/client-registry.ts b/stores/client-registry.ts index 18a245b8..3a3baa36 100644 --- a/stores/client-registry.ts +++ b/stores/client-registry.ts @@ -3,7 +3,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; /** * Tiny indirection used by the calendar and contact stores to look up a * JMAP client by local account ID without importing `auth-store` directly - * — that would form a top-level cycle (auth-store already imports the + * - that would form a top-level cycle (auth-store already imports the * feature stores to bootstrap them after login). * * `auth-store` registers its `getClientForAccount` on module init via diff --git a/stores/email-store.ts b/stores/email-store.ts index add15ed2..76532385 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -23,7 +23,7 @@ interface EmailStore { accountMailboxes: Record; /** * When set, the mail view is reading from this account instead of the - * global active one. `null` means "use the global active account" — i.e. + * global active one. `null` means "use the global active account" - i.e. * the standard single-account behavior. Selecting a folder under a * non-active account in the Pro sidebar updates this without changing * `useAuthStore.activeAccountId`. @@ -249,7 +249,7 @@ function resolveActionMailboxes(): Mailbox[] { * Builds the `UnifiedAccountClient[]` list used by every unified fan-out * action (browse, load-more, search). Each entry has a JMAP client plus a * fresh mailbox list so the helpers can resolve the role mailbox per account. - * Accounts whose mailbox fetch fails are skipped — the unified result will + * Accounts whose mailbox fetch fails are skipped - the unified result will * surface that in its per-account error map. */ async function buildUnifiedAccountClients(): Promise { @@ -1162,7 +1162,7 @@ export const useEmailStore = create((set, get) => ({ })); // Refresh mailbox folder lists/counters for every account we touched. - // Background-only so the move feels instant — counters will catch up. + // Background-only so the move feels instant - counters will catch up. const activeAccountId = useAuthStore.getState().activeAccountId; const touched = new Set([destAccountId, ...emailIdsBySource.keys()]); for (const acctId of touched) { diff --git a/stores/file-store.ts b/stores/file-store.ts index d3ef3cba..115252e2 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -48,7 +48,7 @@ interface FileState { selectedResources: Set; uploadProgress: UploadProgress | null; client: IJMAPClient | null; - /** Which connected account's files are being browsed. Pro shell only — null in single-account contexts. */ + /** Which connected account's files are being browsed. Pro shell only - null in single-account contexts. */ currentAccountId: string | null; clipboard: ClipboardState | null; uploadAbortController: AbortController | null; diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 2d2303db..017b7007 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -155,7 +155,7 @@ export const usePluginStore = create()( })); return; } else { - // 'pending' or 'not-requested' — submit a request and refuse to enable. + // 'pending' or 'not-requested' - submit a request and refuse to enable. await submitApprovalRequest(plugin).catch(() => { /* best effort */ }); set(state => ({ plugins: state.plugins.map(p => @@ -165,12 +165,12 @@ export const usePluginStore = create()( return; } } else if (requireApproval && !policyApproved) { - // No bundleHash means we can't pin the approval — refuse. + // No bundleHash means we can't pin the approval - refuse. return; } // Per-user consent gate: prompt for any permission the user has not - // explicitly approved yet. Managed plugins (admin-pushed) skip this — + // explicitly approved yet. Managed plugins (admin-pushed) skip this - // the admin has already approved them at install time. const implicit = new Set(IMPLICIT_PERMISSIONS); const granted = new Set(plugin.grantedPermissions ?? []); @@ -549,7 +549,7 @@ async function downloadPluginBundle(pluginId: string, bundleHash?: string): Prom // Ed25519 signature verification. Present on every server-managed bundle // since the signing module is server-side; refuse to persist a bundle // that fails verification. If the header is missing (older server / dev - // build with signing disabled) we log and allow — the SHA-256 hash check + // build with signing disabled) we log and allow - the SHA-256 hash check // at load time still catches transport corruption. const sig = res.headers.get('X-Bundle-Signature'); if (sig) { diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index 3e077d05..8db0ef96 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -8,7 +8,7 @@ export type ProTabKind = export type ProPaneId = 'main' | 'split'; /** - * Pro split layout. Only side-by-side is supported — the pane that "splits + * Pro split layout. Only side-by-side is supported - the pane that "splits * off" always lives next to the main pane on the horizontal axis. Kept as * a type alias to leave room for future layouts without churning callers. */ @@ -17,7 +17,7 @@ export type ProSplitOrientation = 'vertical'; export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward'; /** - * Mirror of `EmailComposer.replyTo` — kept as a structural type here so the + * Mirror of `EmailComposer.replyTo` - kept as a structural type here so the * tab store doesn't take a runtime dependency on the composer module. */ export interface ProReplyContext { @@ -92,7 +92,7 @@ interface ProTabState { /** * Move a tab next to another tab. `edge` controls whether it lands before - * or after the target — used by the tab bar's drop indicator. Reordering + * or after the target - used by the tab bar's drop indicator. Reordering * works both within a pane and across panes (cross-pane drops move the * tab to the target pane). */ @@ -476,7 +476,7 @@ export const useProTabStore = create()( { name: 'pro-tabs', version: 3, - // Don't persist transient compose drafts in tab metadata — the composer's + // Don't persist transient compose drafts in tab metadata - the composer's // own draft-store already handles that. Persisted email tabs are fine to // restore (the tab body refetches the email by id). partialize: (state) => ({ diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 3c2ebd7a..caaac19d 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -45,7 +45,7 @@ export type ProtocolOpenMode = 'active-session' | 'new-tab'; /** * Settings that must never round-trip through the cross-device sync API. - * Decided per device and kept only in the local zustand-persist storage — + * Decided per device and kept only in the local zustand-persist storage - * a value already stored on the server (from a prior build) is ignored on * import. */ @@ -520,7 +520,7 @@ export const useSettingsStore = create()( toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher, showRailAccountList: state.showRailAccountList, - // proInterface is intentionally omitted — it's a per-device choice + // proInterface is intentionally omitted - it's a per-device choice // (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced. enableUnifiedMailbox: state.enableUnifiedMailbox, senderFavicons: state.senderFavicons, @@ -853,7 +853,7 @@ if (typeof window !== 'undefined') { syncWarn('Settings sync endpoint returned 404, disabling sync'); syncEnabled = false; } else if (res.status === 403) { - // Identity mismatch — current session cookies don't match the + // Identity mismatch - current session cookies don't match the // username/serverUrl we're syncing for (common in dev mock mode where // no stalwart-context cookie is written, or when rememberMe is off). // Retrying won't help for this session; disable to stop the noise. diff --git a/stores/smime-store.ts b/stores/smime-store.ts index 4299bf23..a0b99b94 100644 --- a/stores/smime-store.ts +++ b/stores/smime-store.ts @@ -19,7 +19,7 @@ import { // Legacy storage key used by an earlier build that persisted unlock passphrases // in sessionStorage. Wipe on module load so any in-flight tab upgrading to this // version doesn't leave plaintext key material sitting around. New code never -// writes here — unlocked CryptoKey handles live only in the in-memory Map below. +// writes here - unlocked CryptoKey handles live only in the in-memory Map below. const LEGACY_REMEMBERED_UNLOCKS_KEY = 'smime-unlocked-session'; if (typeof window !== 'undefined') { try { window.sessionStorage.removeItem(LEGACY_REMEMBERED_UNLOCKS_KEY); } catch { /* ignore */ } From 08c85a42e126dad26ff05f00f01d3108a6b5c7ce Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 23:47:31 +0200 Subject: [PATCH 33/44] chore: update version to 1.7.0 --- CHANGELOG.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- VERSION | 2 +- package-lock.json | 4 +-- package.json | 2 +- 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 024db29e..02cd0440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## 1.7.0 (2026-05-21) + +> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced. + +### Breaking Changes + +- **Plugins**: Plugins now run inside a null-origin iframe sandbox and talk to the host over a postMessage RPC bridge. The in-process plugin runtime is gone; the bundled in-tree plugins have been migrated. Third-party plugins built against the old in-process API need to be ported to the sandboxed runtime. +- **Plugins**: Server-managed bundles must be Ed25519-signed by the host and approved by an admin before they load. The host public key is served from `/api/plugin-signing-pubkey` and each bundle response carries the signature in the `X-Bundle-Signature` header. User-uploaded bundles still load unsigned, but managed marketplace and dev-folder bundles do not. +- **Plugins**: `bundleHash` is now a full SHA-256 over the bundle. Legacy short hashes are migrated on first load; any out-of-band tooling that pinned the old hash format needs to be updated. + +### Features + +- **Pro**: Tabbed shell with drag-to-reorder, drag-to-edge to split, side-by-side panes, and pane-aware responsive layout with a scoped sidebar overlay +- **Pro**: Auto-redirect to the Pro shell when Pro mode is on; `proInterface` is kept per-device instead of syncing +- **Pro**: Multi-account mail sidebar with client routing and a per-account mailbox cache +- **Pro**: Unified mailbox always visible, with full-text search +- **Pro**: Cross-account email moves +- **Pro**: Multi-account calendar sidebar split into owned vs shared per account +- **Pro**: Multi-account contacts and a cross-account file picker +- **Pro**: Composer From dropdown grouped by account +- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load +- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate +- **Setup**: Warn when the JMAP URL points at a local-only host +- **Account**: List and reorder logged-in accounts from settings (#282) +- **Mail**: Mobile handoff page with JMAP authentication verification for cross-device OAuth +- **Mail**: Pluggable reply/forward quote header (#295) +- **Calendar**: Support multiple flexible event reminders (#170) +- **Admin**: Expose PWA, app identity, and extension directory keys in the JSON config (#312) +- **Admin**: Surface OAuth scope settings and wire up orphaned admin policy gates + +### Security + +- **Plugins**: Pin parent origin in the iframe bridge to block cross-frame postMessage +- **Plugins**: Ignore plugin-supplied `target` in `ui.openExternalUrl` to block host-frame hijack +- **Plugins**: Validate plugin/theme id in marketplace install to block path traversal +- **Plugins**: Prevent plugin config from leaking to non-admin users +- **Admin**: Gate admin routes against cross-origin CSRF +- **Auth**: Bind Stalwart auth context to the credential, not the cookie-claimed username +- **Auth**: Validate OAuth discovery endpoints against SSRF +- **Mail**: Tighten HTML sanitization at plain-text email, signature, and i18n render sites +- **Mail**: Block script-bearing MIME types from inline attachment preview +- **Mail**: Escape print-window fields and re-sanitize body to block XSS +- **S/MIME**: Stop persisting passphrases in `sessionStorage` +- **API**: Correct regex for valid API POST path validation + +### Fixes + +- **Mail**: Serialize draft autosave with send to stop replies stalling in Drafts (#303) +- **Mail**: Omit empty cc/bcc from `Email/set` so the server does not emit a bare `Cc:` header (#301) +- **Mobile**: Allow adding contacts from the mail recipient popover (#306) +- **Mobile**: Prevent dual-scroll and use full width for mail content +- **Mobile**: OAuth handoff flow +- **Calendar**: Scope iCal subscriptions per JMAP account; fix refresh and clear +- **Calendar**: iCal subscription refresh, rollback, and URL normalization +- **Calendar**: Show avatars in the calendar/address book sharing menu +- **Contacts**: Normalize malformed contact photo data URIs (#307) +- **Identity**: Clear identity signature fields when emptied +- **Identity**: Show size cap on identity signature fields +- **Identity**: Allow table-based layouts in the HTML signature sanitizer +- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe +- **Plugins**: Sync plugin slot iframe height with reported content height +- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore` +- **Filters**: Prevent duplication of Bulwark rules with literal braces in values +- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch +- **Routing**: Anchor unmatched URLs into `main` so 404 renders +- **Routing**: Respect server-resolved locale on first visit (#309) +- **Routing**: Split app into `(main)`/`(sandbox)` route groups so the plugin iframe hydrates properly +- **Files**: Stop parent directory navigation from jumping to root +- **Build**: Stop pulling `node:dns` into the client bundle via OAuth discovery +- **UI**: Toggle recipient popover when clicking the name again +- **UI**: Remove white halo around photo avatars + +### i18n + +- Add missing translation keys across 16 locales + ## 1.6.7 (2026-05-17) ### Features diff --git a/README.md b/README.md index 2b673748..acd19eab 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.6.7-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) diff --git a/VERSION b/VERSION index 400084b1..bd8bf882 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.7 +1.7.0 diff --git a/package-lock.json b/package-lock.json index fd9fc5dc..ef4c7308 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.6.7", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.6.7", + "version": "1.7.0", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.24", diff --git a/package.json b/package.json index 8494d367..8107670f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.6.7", + "version": "1.7.0", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", From ba4781910d6be7c7e3987f4ef02b029d08852bae Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 00:11:10 +0200 Subject: [PATCH 34/44] feat: marketplace update flow for installed plugins/themes --- app/(main)/admin/_tabs/marketplace.tsx | 52 ++++++++++--- app/(main)/admin/marketplace/[slug]/page.tsx | 52 +++++++++++-- app/api/admin/marketplace/[slug]/route.ts | 9 ++- app/api/admin/marketplace/route.ts | 82 +++++++++++++++----- 4 files changed, 157 insertions(+), 38 deletions(-) diff --git a/app/(main)/admin/_tabs/marketplace.tsx b/app/(main)/admin/_tabs/marketplace.tsx index af2af802..64fda763 100644 --- a/app/(main)/admin/_tabs/marketplace.tsx +++ b/app/(main)/admin/_tabs/marketplace.tsx @@ -2,9 +2,9 @@ import { useEffect, useState, useCallback } from 'react'; import Link from 'next/link'; -import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle } from 'lucide-react'; +import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; -import { isVersionSatisfied } from '@/lib/version-compare'; +import { compareVersions, isVersionSatisfied } from '@/lib/version-compare'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; @@ -21,6 +21,7 @@ interface Extension { minAppVersion: string | null; latestVersion: string | null; installed: boolean; + installedVersion: string | null; iconUrl: string | null; bannerUrl: string | null; author: { @@ -104,6 +105,8 @@ export function MarketplaceTab() { }); return; } + const isUpdate = ext.installed; + const targetVersion = ext.latestVersion || '1.0.0'; setInstalling(ext.slug); setMessage(null); @@ -113,7 +116,7 @@ export function MarketplaceTab() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug: ext.slug, - version: ext.latestVersion || '1.0.0', + version: targetVersion, type: ext.type, }), }); @@ -122,13 +125,22 @@ export function MarketplaceTab() { if (res.ok) { const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; - setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); - setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); + setMessage({ + type: 'success', + text: isUpdate + ? `"${ext.name}" updated to v${targetVersion}${warnings}` + : `"${ext.name}" installed successfully${warnings}`, + }); + setExtensions(prev => prev.map(e => + e.slug === ext.slug + ? { ...e, installed: true, installedVersion: targetVersion } + : e, + )); } else { - setMessage({ type: 'error', text: data.error || 'Installation failed' }); + setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') }); } } catch { - setMessage({ type: 'error', text: 'Installation failed - network error' }); + setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' }); } finally { setInstalling(null); } @@ -270,6 +282,11 @@ function ExtensionCard({ const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; const versionMismatch = !!extension.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion); + const updateAvailable = extension.installed + && !!extension.installedVersion + && !!extension.latestVersion + && compareVersions(extension.latestVersion, extension.installedVersion) > 0 + && !versionMismatch; return (
@@ -359,8 +376,25 @@ function ExtensionCard({
- {extension.installed ? ( - + {extension.installed && updateAvailable ? ( + + ) : extension.installed ? ( + Installed diff --git a/app/(main)/admin/marketplace/[slug]/page.tsx b/app/(main)/admin/marketplace/[slug]/page.tsx index 26fff536..e8095b05 100644 --- a/app/(main)/admin/marketplace/[slug]/page.tsx +++ b/app/(main)/admin/marketplace/[slug]/page.tsx @@ -5,6 +5,7 @@ import { useParams } from 'next/navigation'; import Link from 'next/link'; import { ArrowLeft, + ArrowUpCircle, Download, Loader2, Puzzle, @@ -21,7 +22,7 @@ import { ChevronUp, } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; -import { isVersionSatisfied } from '@/lib/version-compare'; +import { compareVersions, isVersionSatisfied } from '@/lib/version-compare'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; @@ -73,6 +74,7 @@ interface PreviewData { error: string | null; }; installed: boolean; + installedVersion: string | null; } const RISKY_PERMISSIONS = new Set([ @@ -118,6 +120,8 @@ export default function MarketplacePreviewPage() { async function handleInstall() { if (!data) return; + const isUpdate = data.installed; + const targetVersion = data.extension.latestVersion || '1.0.0'; setInstalling(true); setMessage(null); try { @@ -126,20 +130,25 @@ export default function MarketplacePreviewPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug: data.extension.slug, - version: data.extension.latestVersion || '1.0.0', + version: targetVersion, type: data.extension.type, }), }); const body = await res.json(); if (res.ok) { const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : ''; - setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` }); - setData(prev => prev ? { ...prev, installed: true } : prev); + setMessage({ + type: 'success', + text: isUpdate + ? `"${data.extension.name}" updated to v${targetVersion}${warnings}` + : `"${data.extension.name}" installed${warnings}`, + }); + setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev); } else { - setMessage({ type: 'error', text: body.error || 'Installation failed' }); + setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') }); } } catch { - setMessage({ type: 'error', text: 'Installation failed - network error' }); + setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' }); } finally { setInstalling(false); } @@ -204,6 +213,11 @@ export default function MarketplacePreviewPage() { const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || []; const settingsSchema = bundle.manifest?.settingsSchema as Record | undefined; const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion); + const updateAvailable = data.installed + && !!data.installedVersion + && !!ext.latestVersion + && compareVersions(ext.latestVersion, data.installedVersion) > 0 + && !versionMismatch; return (
@@ -248,11 +262,22 @@ export default function MarketplacePreviewPage() {

{ext.name}

{ext.featured && } - {data.installed && ( - + {data.installed && !updateAvailable && ( + Installed )} + {data.installed && updateAvailable && ( + + Update available + + )}
{data.installed ? ( <> + {updateAvailable && ( + + )} t.id === slug) - : pluginRegistry.plugins.some((p) => p.id === slug); + const installedEntry = type === 'theme' + ? themeRegistry.themes.find((t) => t.id === slug) + : pluginRegistry.plugins.find((p) => p.id === slug); + const installed = installedEntry !== undefined; + const installedVersion = installedEntry?.version ?? null; // 4. Build screenshot URLs (proxy through the directory's public files endpoint). const screenshots = Array.isArray(extension.screenshots) @@ -211,6 +213,7 @@ export async function GET( error: bundleError, }, installed, + installedVersion, }, { headers: { 'Cache-Control': 'no-store' } }, ); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 907c37b7..ca31196d 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -5,6 +5,8 @@ import { logger } from '@/lib/logger'; import { savePlugin, saveTheme, + getPlugin, + getTheme, getPluginRegistry, getThemeRegistry, type ServerPlugin, @@ -64,8 +66,12 @@ export async function GET(request: NextRequest) { getThemeRegistry(), ]); - const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id)); - const installedThemes = new Set(themeRegistry.themes.map(t => t.id)); + const installedPluginVersions = new Map( + pluginRegistry.plugins.map(p => [p.id, p.version] as const), + ); + const installedThemeVersions = new Map( + themeRegistry.themes.map(t => [t.id, t.version] as const), + ); const fileUrl = (path: unknown): string | null => typeof path === 'string' && path @@ -73,14 +79,19 @@ export async function GET(request: NextRequest) { : null; if (data.data) { - data.data = data.data.map((ext: Record) => ({ - ...ext, - iconUrl: fileUrl(ext.iconPath), - bannerUrl: fileUrl(ext.bannerPath), - installed: ext.type === 'theme' - ? installedThemes.has(ext.slug as string) - : installedPlugins.has(ext.slug as string), - })); + data.data = data.data.map((ext: Record) => { + const slug = ext.slug as string; + const installedVersion = ext.type === 'theme' + ? installedThemeVersions.get(slug) ?? null + : installedPluginVersions.get(slug) ?? null; + return { + ...ext, + iconUrl: fileUrl(ext.iconPath), + bannerUrl: fileUrl(ext.bannerPath), + installed: installedVersion !== null, + installedVersion, + }; + }); } return NextResponse.json(data, { @@ -200,6 +211,9 @@ export async function POST(request: NextRequest) { warnings.push(...sanitized.warnings); } + const existingTheme = await getTheme(resolvedId); + const isUpdate = existingTheme !== null; + const theme: ServerTheme = { id: resolvedId, name: (manifest.name as string) || slug, @@ -207,15 +221,28 @@ export async function POST(request: NextRequest) { author: (manifest.author as string) || 'Unknown', description: (manifest.description as string) || '', variants: (manifest.variants as string[]) || ['light', 'dark'], - enabled: true, - installedAt: now, + enabled: existingTheme?.enabled ?? true, + ...(existingTheme?.forceEnabled !== undefined + ? { forceEnabled: existingTheme.forceEnabled } + : {}), + installedAt: existingTheme?.installedAt ?? now, updatedAt: now, }; await saveTheme(theme, css); - await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip); + await auditLog( + isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme', + { + id: theme.id, + name: theme.name, + version: theme.version, + slug, + ...(isUpdate ? { previousVersion: existingTheme.version } : {}), + }, + ip, + ); - return NextResponse.json({ success: true, theme, warnings }); + return NextResponse.json({ success: true, theme, warnings, updated: isUpdate }); } else { // Plugin installation // Read entrypoint JS @@ -297,6 +324,9 @@ export async function POST(request: NextRequest) { ); } + const existingPlugin = await getPlugin(resolvedId); + const isUpdate = existingPlugin !== null; + const plugin: ServerPlugin = { id: resolvedId, name: (manifest.name as string) || slug, @@ -306,8 +336,11 @@ export async function POST(request: NextRequest) { type: (manifest.type as string) || 'hook', permissions, entrypoint, - enabled: true, - installedAt: now, + enabled: existingPlugin?.enabled ?? true, + ...(existingPlugin?.forceEnabled !== undefined + ? { forceEnabled: existingPlugin.forceEnabled } + : {}), + installedAt: existingPlugin?.installedAt ?? now, updatedAt: now, ...(manifest.configSchema && typeof manifest.configSchema === 'object' ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } @@ -328,9 +361,22 @@ export async function POST(request: NextRequest) { await savePlugin(plugin, code); invalidateFrameOriginsCache(); - await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip); + await auditLog( + isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin', + { + id: plugin.id, + name: plugin.name, + version: plugin.version, + slug, + frameOrigins: declaredFrameOrigins, + httpOrigins: declaredHttpOrigins, + apiPostPaths: declaredApiPostPaths, + ...(isUpdate ? { previousVersion: existingPlugin.version } : {}), + }, + ip, + ); - return NextResponse.json({ success: true, plugin, warnings }); + return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate }); } } catch (error) { logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); From 63efd724d273cb5277034f340f6a8cbff434fcf1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 00:20:00 +0200 Subject: [PATCH 35/44] fix: trust directory version on marketplace install/update --- app/api/admin/marketplace/route.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index ca31196d..fe54a601 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -217,7 +217,12 @@ export async function POST(request: NextRequest) { const theme: ServerTheme = { id: resolvedId, name: (manifest.name as string) || slug, - version: (manifest.version as string) || version, + // Prefer the directory-published version (what we requested) over + // manifest.version. Publishers sometimes forget to bump the version + // inside the bundle's manifest.json; trusting it would make the + // update never appear to "stick" — the registry would keep showing + // the older version even after a successful update. + version: version || (manifest.version as string), author: (manifest.author as string) || 'Unknown', description: (manifest.description as string) || '', variants: (manifest.variants as string[]) || ['light', 'dark'], @@ -330,7 +335,9 @@ export async function POST(request: NextRequest) { const plugin: ServerPlugin = { id: resolvedId, name: (manifest.name as string) || slug, - version: (manifest.version as string) || version, + // See theme branch: trust the directory-published version, not + // manifest.version, so updates actually stick in the registry. + version: version || (manifest.version as string), author: (manifest.author as string) || 'Unknown', description: (manifest.description as string) || '', type: (manifest.type as string) || 'hook', From 1e7d2d880c10a12b1b309121630a16f71d0e1c59 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 00:44:00 +0200 Subject: [PATCH 36/44] i18n: add missing translation keys across 16 locales --- locales/cs/common.json | 23 ++++++++++++++++++++--- locales/da/common.json | 23 ++++++++++++++++++++--- locales/de/common.json | 11 ++++++++--- locales/es/common.json | 23 ++++++++++++++++++++--- locales/fr/common.json | 23 ++++++++++++++++++++--- locales/it/common.json | 23 ++++++++++++++++++++--- locales/ja/common.json | 23 ++++++++++++++++++++--- locales/ko/common.json | 23 ++++++++++++++++++++--- locales/lv/common.json | 23 ++++++++++++++++++++--- locales/nl/common.json | 23 ++++++++++++++++++++--- locales/pl/common.json | 23 ++++++++++++++++++++--- locales/pt/common.json | 23 ++++++++++++++++++++--- locales/ru/common.json | 23 ++++++++++++++++++++--- locales/tr/common.json | 23 ++++++++++++++++++++--- locales/uk/common.json | 23 ++++++++++++++++++++--- locales/zh/common.json | 23 ++++++++++++++++++++--- package.json | 2 +- 17 files changed, 309 insertions(+), 49 deletions(-) diff --git a/locales/cs/common.json b/locales/cs/common.json index af67b846..d73660db 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Poslední synchronizace", "value": "{time}" + }, + "accounts": { + "title": "Přihlášené účty", + "description": "Přetažením změňte pořadí účtů v rozbalovacím seznamu", + "active": "Aktuálně aktivní účet", + "default_badge": "Výchozí účet", + "set_default": "Nastavit jako výchozí", + "switch_to": "Přepnout na tento účet", + "move_up": "Posunout nahoru", + "move_down": "Posunout dolů", + "drag_handle": "Přetažením změňte pořadí", + "add": "Přidat účet" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "Má e-mail", "has_phone": "Má telefon", "has_photo": "Má fotku" - } + }, + "open_categories": "Otevřít kategorie" }, "calendar": { "title": "Kalendář", @@ -2619,7 +2632,8 @@ "oct": "říj", "nov": "lis", "dec": "pro" - } + }, + "nav_open_menu": "Otevřít nabídku" }, "advanced_search": { "title": "Pokročilé hledání", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Postranní panel", "disabled_title": "Funkce Soubory je zakázána správcem", "disabled_description": "Nahrávání velkých souborů přes WebDAV může způsobit nestabilitu Stalwart/RocksDB, včetně pádů z důvodu nedostatku paměti a nevratného zaplnění disku. Odstraněné soubory nemusí být okamžitě odstraněny z úložiště. Tato funkce se nedoporučuje v produkčním prostředí.", - "stability_warning": "Nahrávání velkých souborů může způsobit nestabilitu serveru. Odstraněné soubory nemusí být okamžitě odstraněny z úložiště. Používejte s opatrností." + "stability_warning": "Nahrávání velkých souborů může způsobit nestabilitu serveru. Odstraněné soubory nemusí být okamžitě odstraněny z úložiště. Používejte s opatrností.", + "no_accounts": "Žádné připojené účty.", + "open_folder_tree": "Otevřít strom složek", + "other_accounts": "Ostatní účty" }, "smime": { "your_certificates": "Vaše certifikáty", diff --git a/locales/da/common.json b/locales/da/common.json index 66c1e56c..7c0d8897 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1202,6 +1202,18 @@ "last_sync": { "label": "Sidste synkronisering", "value": "{time}" + }, + "accounts": { + "title": "Indloggede konti", + "description": "Træk for at ændre rækkefølgen af konti i kontomenuen", + "active": "Aktuelt aktiv konto", + "default_badge": "Standardkonto", + "set_default": "Indstil som standard", + "switch_to": "Skift til denne konto", + "move_up": "Flyt op", + "move_down": "Flyt ned", + "drag_handle": "Træk for at omarrangere", + "add": "Tilføj konto" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "Har e-mail", "has_phone": "Har telefon", "has_photo": "Har billede" - } + }, + "open_categories": "Åbn kategorier" }, "calendar": { "title": "Kalender", @@ -2619,7 +2632,8 @@ "due_today": "I dag", "due_tomorrow": "I morgen", "overdue": "Forfalden" - } + }, + "nav_open_menu": "Åbn menu" }, "sharing": { "title": "Del \"{name}\"", @@ -2797,7 +2811,10 @@ "settings_folder_layout_sidebar": "Sidepanel", "disabled_title": "Filer-funktionen er deaktiveret af din administrator", "disabled_description": "Store filuploads via WebDAV kan forårsage Stalwart/RocksDB-ustabilitet, herunder hukommelsessvigt og uopretteligt diskforbrug. Slettede filer fjernes muligvis ikke straks fra blob-lageret. Denne funktion anbefales ikke til produktionsmiljøer.", - "stability_warning": "Store filuploads kan forårsage serverustabilitet. Slettede filer fjernes muligvis ikke straks fra lageret. Brug med forsigtighed." + "stability_warning": "Store filuploads kan forårsage serverustabilitet. Slettede filer fjernes muligvis ikke straks fra lageret. Brug med forsigtighed.", + "no_accounts": "Ingen tilknyttede konti.", + "open_folder_tree": "Åbn mappetræ", + "other_accounts": "Andre konti" }, "smime": { "your_certificates": "Dine certifikater", diff --git a/locales/de/common.json b/locales/de/common.json index 40a4f82e..ce7e1629 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2259,7 +2259,8 @@ "has_email": "Mit E-Mail", "has_phone": "Mit Telefon", "has_photo": "Mit Foto" - } + }, + "open_categories": "Kategorien öffnen" }, "calendar": { "title": "Kalender", @@ -2631,7 +2632,8 @@ "oct": "Okt", "nov": "Nov", "dec": "Dez" - } + }, + "nav_open_menu": "Menü öffnen" }, "advanced_search": { "title": "Erweiterte Suche", @@ -2786,7 +2788,10 @@ "settings_folder_layout_sidebar": "Seitenleiste", "disabled_title": "Die Dateifunktion wurde von Ihrem Administrator deaktiviert", "disabled_description": "Große Datei-Uploads über WebDAV können Stalwart/RocksDB-Instabilität verursachen, einschließlich Out-of-Memory-Abstürzen und nicht wiederherstellbarer Festplattennutzung. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Blob-Speicher entfernt. Diese Funktion wird für Produktionsumgebungen nicht empfohlen.", - "stability_warning": "Große Datei-Uploads können zu Serverinstabilität führen. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Speicher entfernt. Mit Vorsicht verwenden." + "stability_warning": "Große Datei-Uploads können zu Serverinstabilität führen. Gelöschte Dateien werden möglicherweise nicht sofort aus dem Speicher entfernt. Mit Vorsicht verwenden.", + "no_accounts": "Keine verbundenen Konten.", + "open_folder_tree": "Ordnerbaum öffnen", + "other_accounts": "Andere Konten" }, "smime": { "your_certificates": "Ihre Zertifikate", diff --git a/locales/es/common.json b/locales/es/common.json index 10d2aa31..42c49a7a 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Última Sincronización", "value": "{time}" + }, + "accounts": { + "title": "Cuentas conectadas", + "description": "Arrastra para reordenar cómo aparecen las cuentas en el menú desplegable", + "active": "Cuenta actualmente activa", + "default_badge": "Cuenta predeterminada", + "set_default": "Establecer como predeterminada", + "switch_to": "Cambiar a esta cuenta", + "move_up": "Mover arriba", + "move_down": "Mover abajo", + "drag_handle": "Arrastra para reordenar", + "add": "Añadir cuenta" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "Con correo", "has_phone": "Con teléfono", "has_photo": "Con foto" - } + }, + "open_categories": "Abrir categorías" }, "calendar": { "title": "Calendario", @@ -2619,7 +2632,8 @@ "oct": "Oct", "nov": "Nov", "dec": "Dic" - } + }, + "nav_open_menu": "Abrir menú" }, "advanced_search": { "title": "Búsqueda avanzada", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Barra lateral", "disabled_title": "La función de archivos ha sido desactivada por su administrador", "disabled_description": "Las cargas de archivos grandes a través de WebDAV pueden causar inestabilidad en Stalwart/RocksDB, incluyendo errores de memoria y uso irrecuperable del disco. Los archivos eliminados pueden no eliminarse inmediatamente del almacenamiento. Esta función no se recomienda para entornos de producción.", - "stability_warning": "Las cargas de archivos grandes pueden causar inestabilidad del servidor. Los archivos eliminados pueden no eliminarse inmediatamente del almacenamiento. Usar con precaución." + "stability_warning": "Las cargas de archivos grandes pueden causar inestabilidad del servidor. Los archivos eliminados pueden no eliminarse inmediatamente del almacenamiento. Usar con precaución.", + "no_accounts": "No hay cuentas conectadas.", + "open_folder_tree": "Abrir árbol de carpetas", + "other_accounts": "Otras cuentas" }, "smime": { "your_certificates": "Tus certificados", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1da607b9..15045216 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Dernière synchronisation", "value": "{time}" + }, + "accounts": { + "title": "Comptes connectés", + "description": "Faites glisser pour réorganiser l'affichage des comptes dans le menu déroulant", + "active": "Compte actuellement actif", + "default_badge": "Compte par défaut", + "set_default": "Définir par défaut", + "switch_to": "Basculer vers ce compte", + "move_up": "Déplacer vers le haut", + "move_down": "Déplacer vers le bas", + "drag_handle": "Faire glisser pour réorganiser", + "add": "Ajouter un compte" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "Avec e-mail", "has_phone": "Avec téléphone", "has_photo": "Avec photo" - } + }, + "open_categories": "Ouvrir les catégories" }, "calendar": { "title": "Calendrier", @@ -2619,7 +2632,8 @@ "due_today": "Échéance aujourd'hui", "due_tomorrow": "Échéance demain", "overdue": "En retard" - } + }, + "nav_open_menu": "Ouvrir le menu" }, "advanced_search": { "title": "Recherche avancée", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Barre latérale", "disabled_title": "La fonctionnalité Fichiers a été désactivée par votre administrateur", "disabled_description": "Les téléchargements de fichiers volumineux via WebDAV peuvent provoquer une instabilité de Stalwart/RocksDB, y compris des crashs de mémoire et une utilisation irrécupérable du disque. Les fichiers supprimés peuvent ne pas être immédiatement purgés du stockage. Cette fonctionnalité n'est pas recommandée pour les environnements de production.", - "stability_warning": "Les téléchargements de fichiers volumineux peuvent provoquer une instabilité du serveur. Les fichiers supprimés peuvent ne pas être immédiatement purgés du stockage. À utiliser avec prudence." + "stability_warning": "Les téléchargements de fichiers volumineux peuvent provoquer une instabilité du serveur. Les fichiers supprimés peuvent ne pas être immédiatement purgés du stockage. À utiliser avec prudence.", + "no_accounts": "Aucun compte connecté.", + "open_folder_tree": "Ouvrir l'arborescence des dossiers", + "other_accounts": "Autres comptes" }, "smime": { "your_certificates": "Vos certificats", diff --git a/locales/it/common.json b/locales/it/common.json index 847379cf..30af858c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Ultima sincronizzazione", "value": "{time}" + }, + "accounts": { + "title": "Account connessi", + "description": "Trascina per riordinare la visualizzazione degli account nel menu a discesa", + "active": "Account attualmente attivo", + "default_badge": "Account predefinito", + "set_default": "Imposta come predefinito", + "switch_to": "Passa a questo account", + "move_up": "Sposta su", + "move_down": "Sposta giù", + "drag_handle": "Trascina per riordinare", + "add": "Aggiungi account" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "Con email", "has_phone": "Con telefono", "has_photo": "Con foto" - } + }, + "open_categories": "Apri categorie" }, "calendar": { "title": "Calendario", @@ -2619,7 +2632,8 @@ "oct": "ott", "nov": "nov", "dec": "dic" - } + }, + "nav_open_menu": "Apri menu" }, "advanced_search": { "title": "Ricerca avanzata", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Barra laterale", "disabled_title": "La funzionalità File è stata disabilitata dal tuo amministratore", "disabled_description": "I caricamenti di file di grandi dimensioni tramite WebDAV possono causare instabilità di Stalwart/RocksDB, inclusi crash di memoria e utilizzo irrecuperabile del disco. I file eliminati potrebbero non essere immediatamente rimossi dallo storage. Questa funzionalità non è consigliata per ambienti di produzione.", - "stability_warning": "I caricamenti di file di grandi dimensioni possono causare instabilità del server. I file eliminati potrebbero non essere immediatamente rimossi dallo storage. Usare con cautela." + "stability_warning": "I caricamenti di file di grandi dimensioni possono causare instabilità del server. I file eliminati potrebbero non essere immediatamente rimossi dallo storage. Usare con cautela.", + "no_accounts": "Nessun account collegato.", + "open_folder_tree": "Apri albero cartelle", + "other_accounts": "Altri account" }, "smime": { "your_certificates": "I tuoi certificati", diff --git a/locales/ja/common.json b/locales/ja/common.json index 22427a57..3720b82d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "最終同期", "value": "{time}" + }, + "accounts": { + "title": "ログイン中のアカウント", + "description": "ドラッグしてアカウントメニューに表示される順序を変更", + "active": "現在アクティブなアカウント", + "default_badge": "デフォルトアカウント", + "set_default": "デフォルトに設定", + "switch_to": "このアカウントに切り替え", + "move_up": "上に移動", + "move_down": "下に移動", + "drag_handle": "ドラッグして並べ替え", + "add": "アカウントを追加" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "メールあり", "has_phone": "電話あり", "has_photo": "写真あり" - } + }, + "open_categories": "カテゴリを開く" }, "calendar": { "title": "カレンダー", @@ -2619,7 +2632,8 @@ "oct": "10月", "nov": "11月", "dec": "12月" - } + }, + "nav_open_menu": "メニューを開く" }, "advanced_search": { "title": "詳細検索", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "サイドバー", "disabled_title": "ファイル機能は管理者によって無効にされています", "disabled_description": "WebDAV経由の大容量ファイルアップロードは、メモリ不足クラッシュや回復不能なディスク使用量など、Stalwart/RocksDBの不安定性を引き起こす可能性があります。削除されたファイルはストレージからすぐに削除されない場合があります。この機能は本番環境では推奨されません。", - "stability_warning": "大容量ファイルのアップロードはサーバーの不安定性を引き起こす可能性があります。削除されたファイルはストレージからすぐに削除されない場合があります。注意して使用してください。" + "stability_warning": "大容量ファイルのアップロードはサーバーの不安定性を引き起こす可能性があります。削除されたファイルはストレージからすぐに削除されない場合があります。注意して使用してください。", + "no_accounts": "接続されているアカウントはありません。", + "open_folder_tree": "フォルダーツリーを開く", + "other_accounts": "その他のアカウント" }, "smime": { "your_certificates": "あなたの証明書", diff --git a/locales/ko/common.json b/locales/ko/common.json index 041c0760..617074dd 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "마지막 동기화", "value": "{time}" + }, + "accounts": { + "title": "로그인된 계정", + "description": "끌어서 계정 드롭다운에 표시되는 순서를 변경합니다", + "active": "현재 활성 계정", + "default_badge": "기본 계정", + "set_default": "기본으로 설정", + "switch_to": "이 계정으로 전환", + "move_up": "위로 이동", + "move_down": "아래로 이동", + "drag_handle": "끌어서 순서 변경", + "add": "계정 추가" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "이메일 있음", "has_phone": "전화번호 있음", "has_photo": "사진 있음" - } + }, + "open_categories": "카테고리 열기" }, "calendar": { "title": "캘린더", @@ -2619,7 +2632,8 @@ "oct": "10월", "nov": "11월", "dec": "12월" - } + }, + "nav_open_menu": "메뉴 열기" }, "advanced_search": { "title": "상세 검색", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "사이드바", "disabled_title": "관리자가 파일 기능을 비활성화했어요", "disabled_description": "WebDAV를 통한 대용량 파일 업로드는 Stalwart/RocksDB의 불안정을 일으킬 수 있어요. 메모리 부족 크래시나 복구 불가능한 디스크 사용 문제가 발생할 수 있으며, 삭제된 파일이 즉시 삭제되지 않을 수 있습니다. 운영 환경에서는 이 기능을 권장하지 않아요.", - "stability_warning": "대용량 파일 업로드는 서버 불안정을 초래할 수 있습니다. 삭제된 파일이 즉각적으로 저장소에서 지워지지 않을 수 있으니 주의해서 사용해 주세요." + "stability_warning": "대용량 파일 업로드는 서버 불안정을 초래할 수 있습니다. 삭제된 파일이 즉각적으로 저장소에서 지워지지 않을 수 있으니 주의해서 사용해 주세요.", + "no_accounts": "연결된 계정이 없습니다.", + "open_folder_tree": "폴더 트리 열기", + "other_accounts": "다른 계정" }, "smime": { "your_certificates": "내 인증서", diff --git a/locales/lv/common.json b/locales/lv/common.json index d6cf53a4..7424187c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Pēdējā sinhronizācija", "value": "{time}" + }, + "accounts": { + "title": "Pieteiktie konti", + "description": "Velciet, lai mainītu kontu secību kontu izvēlnē", + "active": "Pašlaik aktīvais konts", + "default_badge": "Noklusējuma konts", + "set_default": "Iestatīt kā noklusējumu", + "switch_to": "Pārslēgties uz šo kontu", + "move_up": "Pārvietot uz augšu", + "move_down": "Pārvietot uz leju", + "drag_handle": "Velciet, lai pārkārtotu", + "add": "Pievienot kontu" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "Ar e-pastu", "has_phone": "Ar tālruni", "has_photo": "Ar foto" - } + }, + "open_categories": "Atvērt kategorijas" }, "calendar": { "title": "Kalendārs", @@ -2619,7 +2632,8 @@ "oct": "okt.", "nov": "nov.", "dec": "dec." - } + }, + "nav_open_menu": "Atvērt izvēlni" }, "advanced_search": { "title": "Izvērstā meklēšana", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Sānu josla", "disabled_title": "Failu funkciju ir atspējojis administrators", "disabled_description": "Lielu failu augšupielāde, izmantojot WebDAV, var radīt Stalwart/RocksDB nestabilitāti, tostarp atmiņas izsīkumu un neatkopjamu diska izmantojumu. Dzēstie faili var netikt nekavējoties izņemti no blob glabātuves. Šī funkcija nav ieteicama produkcijas vidēm.", - "stability_warning": "Lielu failu augšupielāde var radīt servera nestabilitāti. Dzēstie faili var netikt nekavējoties izņemti no glabātuves. Lietojiet piesardzīgi." + "stability_warning": "Lielu failu augšupielāde var radīt servera nestabilitāti. Dzēstie faili var netikt nekavējoties izņemti no glabātuves. Lietojiet piesardzīgi.", + "no_accounts": "Nav pievienotu kontu.", + "open_folder_tree": "Atvērt mapju koku", + "other_accounts": "Citi konti" }, "smime": { "your_certificates": "Jūsu sertifikāti", diff --git a/locales/nl/common.json b/locales/nl/common.json index 72adf8e4..0f3c056f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Laatste synchronisatie", "value": "{time}" + }, + "accounts": { + "title": "Aangemelde accounts", + "description": "Sleep om de volgorde aan te passen waarin accounts in het accountmenu verschijnen", + "active": "Momenteel actief account", + "default_badge": "Standaardaccount", + "set_default": "Als standaard instellen", + "switch_to": "Overschakelen naar dit account", + "move_up": "Omhoog verplaatsen", + "move_down": "Omlaag verplaatsen", + "drag_handle": "Sleep om volgorde aan te passen", + "add": "Account toevoegen" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "Met e-mail", "has_phone": "Met telefoon", "has_photo": "Met foto" - } + }, + "open_categories": "Categorieën openen" }, "calendar": { "title": "Agenda", @@ -2619,7 +2632,8 @@ "oct": "okt", "nov": "nov", "dec": "dec" - } + }, + "nav_open_menu": "Menu openen" }, "advanced_search": { "title": "Geavanceerd zoeken", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Zijbalk", "disabled_title": "De bestandsfunctie is uitgeschakeld door uw beheerder", "disabled_description": "Grote bestandsuploads via WebDAV kunnen Stalwart/RocksDB-instabiliteit veroorzaken, waaronder geheugenfouten en onherstelbaar schijfgebruik. Verwijderde bestanden worden mogelijk niet onmiddellijk uit de opslag verwijderd. Deze functie wordt niet aanbevolen voor productieomgevingen.", - "stability_warning": "Grote bestandsuploads kunnen serverinstabiliteit veroorzaken. Verwijderde bestanden worden mogelijk niet onmiddellijk uit de opslag verwijderd. Gebruik met voorzichtigheid." + "stability_warning": "Grote bestandsuploads kunnen serverinstabiliteit veroorzaken. Verwijderde bestanden worden mogelijk niet onmiddellijk uit de opslag verwijderd. Gebruik met voorzichtigheid.", + "no_accounts": "Geen gekoppelde accounts.", + "open_folder_tree": "Mappenstructuur openen", + "other_accounts": "Andere accounts" }, "smime": { "your_certificates": "Uw certificaten", diff --git a/locales/pl/common.json b/locales/pl/common.json index 25f56e20..4c562e26 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Ostatnia synchronizacja", "value": "{time}" + }, + "accounts": { + "title": "Zalogowane konta", + "description": "Przeciągnij, aby zmienić kolejność wyświetlania kont w menu rozwijanym", + "active": "Aktualnie aktywne konto", + "default_badge": "Konto domyślne", + "set_default": "Ustaw jako domyślne", + "switch_to": "Przełącz na to konto", + "move_up": "Przesuń w górę", + "move_down": "Przesuń w dół", + "drag_handle": "Przeciągnij, aby zmienić kolejność", + "add": "Dodaj konto" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "Z e-mailem", "has_phone": "Z telefonem", "has_photo": "Ze zdjęciem" - } + }, + "open_categories": "Otwórz kategorie" }, "calendar": { "title": "Kalendarz", @@ -2619,7 +2632,8 @@ "oct": "paź", "nov": "lis", "dec": "gru" - } + }, + "nav_open_menu": "Otwórz menu" }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Pasek boczny", "disabled_title": "Funkcja Pliki jest wyłączona przez administratora", "disabled_description": "Duże przesyłanie plików przez WebDAV może powodować niestabilność Stalwart/RocksDB, w tym awarie z powodu braku pamięci i nieodwracalne zużycie miejsca na dysku. Usunięte pliki mogą nie zostać natychmiast usunięte z magazynu blob. Ta funkcja nie jest zalecana w środowiskach produkcyjnych.", - "stability_warning": "Duże przesyłanie plików może powodować niestabilność serwera. Usunięte pliki mogą nie zostać natychmiast usunięte z magazynu. Używaj ostrożnie." + "stability_warning": "Duże przesyłanie plików może powodować niestabilność serwera. Usunięte pliki mogą nie zostać natychmiast usunięte z magazynu. Używaj ostrożnie.", + "no_accounts": "Brak połączonych kont.", + "open_folder_tree": "Otwórz drzewo folderów", + "other_accounts": "Inne konta" }, "smime": { "your_certificates": "Twoje certyfikaty", diff --git a/locales/pt/common.json b/locales/pt/common.json index c0566111..9fa15a91 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Última Sincronização", "value": "{time}" + }, + "accounts": { + "title": "Contas conectadas", + "description": "Arraste para reordenar como as contas aparecem no menu suspenso", + "active": "Conta atualmente ativa", + "default_badge": "Conta padrão", + "set_default": "Definir como padrão", + "switch_to": "Mudar para esta conta", + "move_up": "Mover para cima", + "move_down": "Mover para baixo", + "drag_handle": "Arraste para reordenar", + "add": "Adicionar conta" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "Com e-mail", "has_phone": "Com telefone", "has_photo": "Com foto" - } + }, + "open_categories": "Abrir categorias" }, "calendar": { "title": "Calendário", @@ -2619,7 +2632,8 @@ "due_today": "Vence hoje", "due_tomorrow": "Vence amanhã", "overdue": "Atrasada" - } + }, + "nav_open_menu": "Abrir menu" }, "advanced_search": { "title": "Pesquisa avançada", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Barra lateral", "disabled_title": "O recurso de arquivos foi desativado pelo seu administrador", "disabled_description": "Uploads de arquivos grandes via WebDAV podem causar instabilidade no Stalwart/RocksDB, incluindo falhas de memória e uso irrecuperável de disco. Arquivos excluídos podem não ser removidos imediatamente do armazenamento. Este recurso não é recomendado para ambientes de produção.", - "stability_warning": "Uploads de arquivos grandes podem causar instabilidade no servidor. Arquivos excluídos podem não ser removidos imediatamente do armazenamento. Use com cautela." + "stability_warning": "Uploads de arquivos grandes podem causar instabilidade no servidor. Arquivos excluídos podem não ser removidos imediatamente do armazenamento. Use com cautela.", + "no_accounts": "Nenhuma conta conectada.", + "open_folder_tree": "Abrir árvore de pastas", + "other_accounts": "Outras contas" }, "smime": { "your_certificates": "Seus certificados", diff --git a/locales/ru/common.json b/locales/ru/common.json index 54687350..8d010220 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Последняя синхронизация", "value": "{time}" + }, + "accounts": { + "title": "Подключённые учётные записи", + "description": "Перетащите, чтобы изменить порядок отображения учётных записей в меню", + "active": "Текущая активная учётная запись", + "default_badge": "По умолчанию", + "set_default": "Сделать основной", + "switch_to": "Переключиться на эту учётную запись", + "move_up": "Переместить вверх", + "move_down": "Переместить вниз", + "drag_handle": "Перетащите, чтобы изменить порядок", + "add": "Добавить учётную запись" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "С эл. почтой", "has_phone": "С телефоном", "has_photo": "С фото" - } + }, + "open_categories": "Открыть категории" }, "calendar": { "title": "Календарь", @@ -2619,7 +2632,8 @@ "oct": "окт.", "nov": "нояб.", "dec": "дек." - } + }, + "nav_open_menu": "Открыть меню" }, "advanced_search": { "title": "Расширенный поиск", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Боковая панель", "disabled_title": "Функция файлов отключена вашим администратором", "disabled_description": "Загрузка больших файлов через WebDAV может вызвать нестабильность Stalwart/RocksDB, включая ошибки нехватки памяти и невосстановимое использование диска. Удалённые файлы могут не быть немедленно удалены из хранилища. Эта функция не рекомендуется для рабочих сред.", - "stability_warning": "Загрузка больших файлов может вызвать нестабильность сервера. Удалённые файлы могут не быть немедленно удалены из хранилища. Используйте с осторожностью." + "stability_warning": "Загрузка больших файлов может вызвать нестабильность сервера. Удалённые файлы могут не быть немедленно удалены из хранилища. Используйте с осторожностью.", + "no_accounts": "Нет подключённых учётных записей.", + "open_folder_tree": "Открыть дерево папок", + "other_accounts": "Другие учётные записи" }, "smime": { "your_certificates": "Ваши сертификаты", diff --git a/locales/tr/common.json b/locales/tr/common.json index 3c343cfa..12b079b9 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Son Senkronizasyon", "value": "{time}" + }, + "accounts": { + "title": "Oturum açılmış hesaplar", + "description": "Hesapların hesap menüsünde görüntülenme sırasını değiştirmek için sürükleyin", + "active": "Şu anda etkin hesap", + "default_badge": "Varsayılan hesap", + "set_default": "Varsayılan olarak ayarla", + "switch_to": "Bu hesaba geç", + "move_up": "Yukarı taşı", + "move_down": "Aşağı taşı", + "drag_handle": "Sıralamak için sürükleyin", + "add": "Hesap ekle" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "E-postası var", "has_phone": "Telefonu var", "has_photo": "Fotoğrafı var" - } + }, + "open_categories": "Kategorileri aç" }, "calendar": { "title": "Takvim", @@ -2619,7 +2632,8 @@ "due_today": "Bugün", "due_tomorrow": "Yarın", "overdue": "Gecikmiş" - } + }, + "nav_open_menu": "Menüyü aç" }, "sharing": { "title": "\"{name}\" paylaş", @@ -2797,7 +2811,10 @@ "settings_folder_layout_sidebar": "Kenar Çubuğu", "disabled_title": "Dosyalar özelliği yöneticiniz tarafından devre dışı bırakıldı", "disabled_description": "WebDAV üzerinden büyük dosya yüklemeleri Stalwart/RocksDB kararsızlığına yol açabilir; bellek yetersizliği çöküşleri ve kurtarılamaz disk kullanımı dahil. Silinen dosyalar blob depolamadan hemen temizlenmeyebilir. Bu özellik üretim ortamları için önerilmez.", - "stability_warning": "Büyük dosya yüklemeleri sunucu kararsızlığına neden olabilir. Silinen dosyalar depolamadan hemen temizlenmeyebilir. Dikkatli kullanın." + "stability_warning": "Büyük dosya yüklemeleri sunucu kararsızlığına neden olabilir. Silinen dosyalar depolamadan hemen temizlenmeyebilir. Dikkatli kullanın.", + "no_accounts": "Bağlı hesap yok.", + "open_folder_tree": "Klasör ağacını aç", + "other_accounts": "Diğer hesaplar" }, "smime": { "your_certificates": "Sertifikalarınız", diff --git a/locales/uk/common.json b/locales/uk/common.json index 7e182fe9..e3e53f59 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "Остання синхронізація", "value": "{time}" + }, + "accounts": { + "title": "Підключені облікові записи", + "description": "Перетягніть, щоб змінити порядок відображення облікових записів у меню", + "active": "Поточний активний обліковий запис", + "default_badge": "Обліковий запис за замовчуванням", + "set_default": "Зробити основним", + "switch_to": "Перемкнутися на цей обліковий запис", + "move_up": "Перемістити вгору", + "move_down": "Перемістити вниз", + "drag_handle": "Перетягніть, щоб змінити порядок", + "add": "Додати обліковий запис" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "З ел. поштою", "has_phone": "З телефоном", "has_photo": "З фото" - } + }, + "open_categories": "Відкрити категорії" }, "calendar": { "title": "Календар", @@ -2619,7 +2632,8 @@ "oct": "жовт.", "nov": "лист.", "dec": "груд." - } + }, + "nav_open_menu": "Відкрити меню" }, "advanced_search": { "title": "Розширений пошук", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "Бічна панель", "disabled_title": "Функцію файлів вимкнено вашим адміністратором", "disabled_description": "Завантаження великих файлів через WebDAV може спричинити нестабільність Stalwart/RocksDB, зокрема збої через брак пам’яті та невідновне використання диска. Видалені файли не можуть бути негайно очищені зі сховища BLOB-об’єктів. Ця функція не рекомендована для робочих середовищ.", - "stability_warning": "Завантаження великих файлів може спричинити нестабільність сервера. Видалені файли не можуть бути негайно видалені зі сховища. Використовуйте з обережністю." + "stability_warning": "Завантаження великих файлів може спричинити нестабільність сервера. Видалені файли не можуть бути негайно видалені зі сховища. Використовуйте з обережністю.", + "no_accounts": "Немає підключених облікових записів.", + "open_folder_tree": "Відкрити дерево тек", + "other_accounts": "Інші облікові записи" }, "smime": { "your_certificates": "Ваші сертифікати", diff --git a/locales/zh/common.json b/locales/zh/common.json index f18f7767..c905318e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1199,6 +1199,18 @@ "last_sync": { "label": "上次同步", "value": "{time}" + }, + "accounts": { + "title": "已登录账户", + "description": "拖动以重新排序账户在账户下拉菜单中的显示顺序", + "active": "当前活动账户", + "default_badge": "默认账户", + "set_default": "设为默认", + "switch_to": "切换到此账户", + "move_up": "上移", + "move_down": "下移", + "drag_handle": "拖动以重新排序", + "add": "添加账户" } }, "security": { @@ -2247,7 +2259,8 @@ "has_email": "有邮箱", "has_phone": "有电话", "has_photo": "有照片" - } + }, + "open_categories": "打开分类" }, "calendar": { "title": "日历", @@ -2619,7 +2632,8 @@ "oct": "10月", "nov": "11月", "dec": "12月" - } + }, + "nav_open_menu": "打开菜单" }, "advanced_search": { "title": "高级搜索", @@ -2774,7 +2788,10 @@ "settings_folder_layout_sidebar": "侧边栏", "disabled_title": "文件功能已被您的管理员禁用", "disabled_description": "通过 WebDAV 上传大文件可能导致 Stalwart/RocksDB 不稳定,包括内存溢出、崩溃以及难以回收的磁盘占用。已删除文件也可能不会立即从 blob 存储中清除。不建议在生产环境中启用此功能。", - "stability_warning": "大文件上传会导致服务器不稳定。已删除的文件可能不会立即从存储中清除。谨慎使用。" + "stability_warning": "大文件上传会导致服务器不稳定。已删除的文件可能不会立即从存储中清除。谨慎使用。", + "no_accounts": "没有已连接的账户。", + "open_folder_tree": "打开文件夹树", + "other_accounts": "其他账户" }, "smime": { "your_certificates": "您的证书", diff --git a/package.json b/package.json index 8107670f..7d8505d1 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "start": "next start", "lint": "next lint", "lint:fix": "next lint --fix", - "test:translations": "vitest run lib/__tests__/translations.test.ts", + "test:translations": "vitest run --no-isolate lib/__tests__/translations.test.ts", "prepare": "husky", "typecheck": "tsc --noEmit" }, From 7142627cec4bf7473f6e33f78ee69b9b18bd0c0b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 00:51:38 +0200 Subject: [PATCH 37/44] chore: update version to 1.7.0 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02cd0440..358dd5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - **Pro**: Multi-account contacts and a cross-account file picker - **Pro**: Composer From dropdown grouped by account - **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load +- **Plugins**: Marketplace update flow for installed plugins and themes - **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate - **Setup**: Warn when the JMAP URL points at a local-only host - **Account**: List and reorder logged-in accounts from settings (#282) @@ -62,6 +63,7 @@ - **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe - **Plugins**: Sync plugin slot iframe height with reported content height - **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore` +- **Plugins**: Trust the directory version on marketplace install and update - **Filters**: Prevent duplication of Bulwark rules with literal braces in values - **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch - **Routing**: Anchor unmatched URLs into `main` so 404 renders From bd2ffab3bcd1b26fc321b07da433aeb40d4da8bf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 11:20:50 +0200 Subject: [PATCH 38/44] fix: resolve destination account id to local namespace in mailbox drop --- hooks/use-mailbox-drop.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index e018bd04..32026c7f 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -21,6 +21,27 @@ function resolveSourceAccountId(email: Email | undefined): string | null { return useAuthStore.getState().activeAccountId; } +/** + * Returns the local accountId ("user@host") that owns the destination + * mailbox. `mailbox.accountId` is the JMAP server's opaque account id, but + * `clients`, `activeAccountId`, and `email.accountId` all live in the local + * namespace. We map back by matching the JMAP id against each connected + * client's `getAccountId()`. Falls back to the viewing/active account so + * single-account flows (no connected clients map entry yet, in-memory edits, + * etc.) still resolve correctly. + */ +function resolveDestAccountId(mailbox: Mailbox): string | null { + const jmapId = mailbox.accountId; + if (jmapId) { + const clients = useAuthStore.getState().getAllConnectedClients(); + for (const [localId, client] of clients) { + if (client.getAccountId() === jmapId) return localId; + } + } + return useEmailStore.getState().viewingAccountId + ?? useAuthStore.getState().activeAccountId; +} + interface UseMailboxDropOptions { mailbox: Mailbox; onDropComplete?: () => void; @@ -123,7 +144,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: // Group dragged emails by source account. In single-account flows this // collapses to one bucket; in unified view or the Pro multi-account // sidebar a single drag can mix sources. - const destAccountId = mailbox.accountId; + const destAccountId = resolveDestAccountId(mailbox); const idToEmail = new Map(draggedEmails.map((em) => [em.id, em])); const bySource = new Map(); for (const id of emailIds) { From 66b2036e372efb9f3b4def504df7379d4750ecc7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 11:22:07 +0200 Subject: [PATCH 39/44] feat: expose PWA branding fields in admin Branding tab --- app/(main)/admin/_tabs/branding.tsx | 150 ++++++++++++++++++++++++++++ app/api/admin/branding/route.ts | 1 + app/api/pwa-icon/[size]/route.ts | 22 +++- 3 files changed, 168 insertions(+), 5 deletions(-) diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index 2c9a4fa5..b9426697 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -25,6 +25,20 @@ const TEXT_FIELDS = [ { key: 'loginWebsiteUrl', label: 'Company Website URL' }, ]; +const PWA_IMAGE_FIELDS = [ + { key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' }, +]; + +const PWA_TEXT_FIELDS = [ + { key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' }, + { key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' }, +]; + +const PWA_COLOR_FIELDS = [ + { key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' }, + { key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' }, +]; + export function BrandingTab() { const [config, setConfig] = useState>({}); const [edits, setEdits] = useState>({}); @@ -262,6 +276,142 @@ export function BrandingTab() {
+
+
+

Progressive Web App

+

Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.

+
+
+ {PWA_IMAGE_FIELDS.map(field => ( +
+
+
+ + {config[field.key]?.source === 'admin' && ( + + {isUploadedFile(field.key) ? 'uploaded' : 'admin'} + + )} +
+
+ handleChange(field.key, e.target.value)} + placeholder="Enter URL or upload a file" + className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + { fileInputRefs.current[field.key] = el; }} + type="file" + accept={field.accept} + className="hidden" + onChange={(e) => { + const file = e.target.files?.[0]; + if (file) handleUpload(field.key, file); + e.target.value = ''; + }} + /> + + {isUploadedFile(field.key) && ( + + )} + {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( + + )} +
+
+ {currentValue(field.key) && ( +
+ +
+ {field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+
+ )} +
+ ))} + {PWA_TEXT_FIELDS.map(field => ( +
+
+ + {config[field.key]?.source === 'admin' && ( + admin + )} +
+
+ handleChange(field.key, e.target.value)} + placeholder={field.placeholder} + className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + {config[field.key]?.source === 'admin' && ( + + )} +
+
+ ))} + {PWA_COLOR_FIELDS.map(field => { + const value = currentValue(field.key) || field.defaultValue; + return ( +
+
+ + {config[field.key]?.source === 'admin' && ( + admin + )} +
+
+ handleChange(field.key, e.target.value)} + className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5" + title="Pick a color" + /> + handleChange(field.key, e.target.value)} + placeholder={field.defaultValue} + className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + {config[field.key]?.source === 'admin' && ( + + )} +
+
+ ); + })} +
+
+

Company Information

diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index 9d0e8242..de9b4457 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -24,6 +24,7 @@ const ALLOWED_MIME_TYPES = new Set([ /** Slots that correspond to branding config keys */ const VALID_SLOTS = new Set([ 'faviconUrl', + 'pwaIconUrl', 'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', diff --git a/app/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts index 14977870..54ad6c4a 100644 --- a/app/api/pwa-icon/[size]/route.ts +++ b/app/api/pwa-icon/[size]/route.ts @@ -3,11 +3,13 @@ import sharp from 'sharp'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; import { configManager } from '@/lib/admin/config-manager'; +import { getConfigDir } from '@/lib/admin/paths'; const VALID_SIZES = new Set([192, 512]); -// Cache resized images in memory to avoid reprocessing on every request -const cache = new Map(); +// Cache resized images keyed by (size, source URL) so admin re-uploads or URL +// changes invalidate the prior render instead of serving stale bytes forever. +const cache = new Map(); async function fetchSourceImage(iconUrl: string): Promise { // Absolute URL (http/https) @@ -17,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise { return Buffer.from(await res.arrayBuffer()); } + // Admin-uploaded branding asset: served from /api/admin/branding/ + // but stored on disk under getConfigDir()/branding/. + const ADMIN_BRANDING_PREFIX = '/api/admin/branding/'; + if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) { + const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length)); + return readFile(path.join(getConfigDir(), 'branding', filename)); + } + // Path relative to public/ directory const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, '')); return readFile(publicPath); @@ -47,9 +57,11 @@ export async function GET( 'Cache-Control': 'public, max-age=86400', }; + const cacheKey = `${size}|${iconUrl}`; + try { - if (cache.has(size)) { - return new NextResponse(cache.get(size)!, { headers: pngHeaders }); + if (cache.has(cacheKey)) { + return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders }); } const sourceBuffer = await fetchSourceImage(iconUrl); @@ -61,7 +73,7 @@ export async function GET( const ab = new ArrayBuffer(resized.byteLength); new Uint8Array(ab).set(resized); const blob = new Blob([ab], { type: 'image/png' }); - cache.set(size, blob); + cache.set(cacheKey, blob); return new NextResponse(blob, { headers: pngHeaders }); } catch (err) { From 1c02970ae134bdb408888b993f05d41366e567bf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 11:51:25 +0200 Subject: [PATCH 40/44] fix: use canonical INBOX in Sieve filter paths #313 --- components/filters/filter-rule-modal.tsx | 5 +++- lib/__tests__/mailbox-path.test.ts | 33 ++++++++++++++++++------ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 96c7b293..25b0eb9b 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -78,7 +78,10 @@ export function FilterRuleModal({ const pathMap = new Map(); const buildPaths = (nodes: MailboxNode[], parentPath = "") => { for (const node of nodes) { - const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name; + // Sieve fileinto expects the IMAP-canonical "INBOX" for the inbox, + // not the localized JMAP display name (e.g. "Entrada" in pt-BR). + const segment = node.role === "inbox" ? "INBOX" : node.name; + const fullPath = parentPath ? `${parentPath}/${segment}` : segment; pathMap.set(node.id, fullPath); if (node.children.length > 0) buildPaths(node.children, fullPath); } diff --git a/lib/__tests__/mailbox-path.test.ts b/lib/__tests__/mailbox-path.test.ts index a0cfd24a..238a492c 100644 --- a/lib/__tests__/mailbox-path.test.ts +++ b/lib/__tests__/mailbox-path.test.ts @@ -33,7 +33,8 @@ function buildMailboxPathMap(tree: MailboxNode[]): Map { const pathMap = new Map(); const walk = (nodes: MailboxNode[], parentPath = '') => { for (const node of nodes) { - const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name; + const segment = node.role === 'inbox' ? 'INBOX' : node.name; + const fullPath = parentPath ? `${parentPath}/${segment}` : segment; pathMap.set(node.id, fullPath); if (node.children.length > 0) walk(node.children, fullPath); } @@ -47,7 +48,7 @@ describe('mailbox path building for sieve fileinto', () => { const mailboxes = [makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' })]; const tree = buildMailboxTree(mailboxes); const paths = buildMailboxPathMap(tree); - expect(paths.get('inbox')).toBe('Inbox'); + expect(paths.get('inbox')).toBe('INBOX'); }); it('should produce correct path for a single-level subfolder', () => { @@ -57,7 +58,7 @@ describe('mailbox path building for sieve fileinto', () => { ]; const tree = buildMailboxTree(mailboxes); const paths = buildMailboxPathMap(tree); - expect(paths.get('sub1')).toBe('Inbox/Projects'); + expect(paths.get('sub1')).toBe('INBOX/Projects'); }); it('should produce correct path for deeply nested subfolders', () => { @@ -68,7 +69,7 @@ describe('mailbox path building for sieve fileinto', () => { ]; const tree = buildMailboxTree(mailboxes); const paths = buildMailboxPathMap(tree); - expect(paths.get('sub2')).toBe('Inbox/Test/Test2'); + expect(paths.get('sub2')).toBe('INBOX/Test/Test2'); }); it('should handle multiple root-level folders', () => { @@ -79,7 +80,7 @@ describe('mailbox path building for sieve fileinto', () => { ]; const tree = buildMailboxTree(mailboxes); const paths = buildMailboxPathMap(tree); - expect(paths.get('inbox')).toBe('Inbox'); + expect(paths.get('inbox')).toBe('INBOX'); expect(paths.get('archive')).toBe('Archive'); expect(paths.get('sub1')).toBe('Archive/Work'); }); @@ -99,9 +100,25 @@ describe('mailbox path building for sieve fileinto', () => { expect(paths.has(node.id)).toBe(true); } - expect(paths.get('inbox')).toBe('Inbox'); - expect(paths.get('sub1')).toBe('Inbox/Projects'); - expect(paths.get('sub2')).toBe('Inbox/Projects/Active'); + expect(paths.get('inbox')).toBe('INBOX'); + expect(paths.get('sub1')).toBe('INBOX/Projects'); + expect(paths.get('sub2')).toBe('INBOX/Projects/Active'); + }); + + it('uses canonical INBOX even when JMAP returns a localized inbox name', () => { + // Stalwart returns localized display names for the inbox based on the + // user's locale (e.g. "Entrada" for pt-BR). Sieve fileinto must still + // target the IMAP-canonical "INBOX" so the message is filed correctly. + const mailboxes = [ + makeMailbox({ id: 'inbox', name: 'Entrada', role: 'inbox' }), + makeMailbox({ id: 'host', name: 'Host', parentId: 'inbox' }), + makeMailbox({ id: 'eveo', name: 'EVEO', parentId: 'host' }), + ]; + const tree = buildMailboxTree(mailboxes); + const paths = buildMailboxPathMap(tree); + expect(paths.get('inbox')).toBe('INBOX'); + expect(paths.get('host')).toBe('INBOX/Host'); + expect(paths.get('eveo')).toBe('INBOX/Host/EVEO'); }); it('should preserve depth info in flattened tree', () => { From 704a25943294f12243432eb9db6cc4c77b3596b7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 12:04:40 +0200 Subject: [PATCH 41/44] feat: hide empty-state placeholder in email viewer pane --- components/email/email-viewer.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 5cb2d7bd..1ad137fb 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3252,21 +3252,7 @@ export function EmailViewer({ ); } return ( -
-
-
- -
-

{t('no_conversation_selected')}

-

{t('no_conversation_description')}

- {onCompose && ( - - )} -
-
+
); } From ac4a89120de5fcdee9ebe5f9dcdfed00281e9dce Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 12:06:55 +0200 Subject: [PATCH 42/44] fix: preserve inline images when replying #163 --- components/email/email-composer.tsx | 82 +++++++++++++++++++- components/email/resizable-image.tsx | 12 ++- lib/__tests__/email-composer-utils.test.ts | 90 +++++++++++++++++++++- lib/email-composer-utils.ts | 56 ++++++++++++++ 4 files changed, 236 insertions(+), 4 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 98b89989..90704e69 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -35,6 +35,10 @@ import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { resolveReplyFrom } from "@/lib/reply-identity"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; +import { + rewriteCidImagesForEditor, + replaceInlineImagePlaceholders, +} from "@/lib/email-composer-utils"; import { RichTextEditor } from "@/components/email/rich-text-editor"; import type { Editor } from "@tiptap/react"; @@ -300,7 +304,8 @@ export function EmailComposer({ if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { const wrap = replyTo.quoteWrapInBlockquote !== false; const originalHtml = replyTo.htmlBody - ?? (replyTo.body + ? rewriteCidImagesForEditor(replyTo.htmlBody) + : (replyTo.body ? replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') : ''); const bodyHtml = wrap @@ -314,7 +319,10 @@ export function EmailComposer({ const quoteHeader = mode === 'forward' ? `---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

` : `On ${date}, ${fromStr} wrote:
`; - return `${prefix}${signatureBlock}
${quoteHeader}
${replyTo.htmlBody}
`; + // cid: image refs are rewritten so they render in the editor (browsers + // can't fetch cid: URLs); see useEffect below for the data-URL backfill. + const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody); + return `${prefix}${signatureBlock}
${quoteHeader}
${quotedHtml}
`; } if (replyTo.body) { @@ -534,6 +542,76 @@ export function EmailComposer({ selectedIdentityId, ]); + // Hydrate inline images referenced by the quoted body (issue #163). + // `getInitialBody` rewrites `` to placeholder src + + // data-cid; here we (1) register each inline attachment in inlineImagesRef + // so the send path re-attaches the blob with the right cid, and (2) fetch + // each blob as a data URL and swap it into the body so the editor actually + // shows the image instead of a blank placeholder. + useEffect(() => { + if (plainTextMode) return; + if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return; + if (!composerClient || !replyTo?.attachments?.length) return; + + const inlineAtts = replyTo.attachments.filter((att) => + att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/') + ); + if (inlineAtts.length === 0) return; + + // Seed the ref synchronously so a fast Send still attaches the right blobs + // even if the FileReader work below hasn't resolved yet. + for (const att of inlineAtts) { + if (!att.cid) continue; + if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue; + inlineImagesRef.current.push({ + cid: att.cid, + blobId: att.blobId, + type: att.type, + name: att.name || 'inline', + size: att.size, + dataUrl: '', + }); + } + + let cancelled = false; + (async () => { + const updates = new Map(); + for (const att of inlineAtts) { + if (!att.cid) continue; + try { + const buffer = await composerClient.fetchBlobArrayBuffer( + att.blobId, + att.name || 'inline', + att.type, + ); + if (cancelled) return; + const blob = new Blob([buffer], { type: att.type }); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); + if (cancelled) return; + const entry = inlineImagesRef.current.find((e) => e.cid === att.cid); + if (entry) entry.dataUrl = dataUrl; + updates.set(att.cid, dataUrl); + } catch (err) { + debug.error('Failed to load inline image for compose', err); + } + } + if (cancelled || updates.size === 0) return; + setBody((prev) => replaceInlineImagePlaceholders(prev, updates)); + })(); + + return () => { + cancelled = true; + }; + // We deliberately hydrate once per composer open - subsequent replyTo + // object identity churn from parent renders shouldn't refetch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [composerClient, plainTextMode, mode]); + const composerSignatureHtml = signatureIdentity?.htmlSignature ? `
${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}
` : signatureIdentity?.textSignature diff --git a/components/email/resizable-image.tsx b/components/email/resizable-image.tsx index 4fdf9d44..a6e9e611 100644 --- a/components/email/resizable-image.tsx +++ b/components/email/resizable-image.tsx @@ -115,7 +115,17 @@ export const ResizableImage = Node.create({ width: { default: null }, cid: { default: null, - parseHTML: (el) => el.getAttribute("data-cid"), + parseHTML: (el) => { + const dataCid = el.getAttribute("data-cid"); + if (dataCid) return dataCid; + // Fall back to deriving the cid from `src="cid:xxx"` so inline + // image refs survive editor round-trips even when data-cid was + // never set (defensive — the composer normally pre-rewrites + // quoted-body cid: refs into data-cid). + const src = el.getAttribute("src") || ""; + if (/^cid:/i.test(src)) return src.slice(4) || null; + return null; + }, renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}), }, }; diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts index 5aecc21e..0daa17b4 100644 --- a/lib/__tests__/email-composer-utils.test.ts +++ b/lib/__tests__/email-composer-utils.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { plainTextToComposerBody } from "../email-composer-utils"; +import { + plainTextToComposerBody, + rewriteCidImagesForEditor, + replaceInlineImagePlaceholders, + INLINE_IMAGE_PLACEHOLDER, +} from "../email-composer-utils"; describe("plainTextToComposerBody", () => { it("returns an empty string for empty input", () => { @@ -24,3 +29,86 @@ describe("plainTextToComposerBody", () => { ); }); }); + +describe("rewriteCidImagesForEditor", () => { + it("returns input unchanged when no cid: refs are present", () => { + const html = '

hi

'; + expect(rewriteCidImagesForEditor(html)).toBe(html); + }); + + it("handles empty input", () => { + expect(rewriteCidImagesForEditor("")).toBe(""); + }); + + it("rewrites a cid: src to placeholder + data-cid", () => { + const out = rewriteCidImagesForEditor( + 'logo' + ); + expect(out).toContain('data-cid="abc@x"'); + expect(out).toContain(`src="${INLINE_IMAGE_PLACEHOLDER}"`); + expect(out).toContain('alt="logo"'); + expect(out).not.toContain('src="cid:'); + }); + + it("preserves an existing data-cid attribute", () => { + const out = rewriteCidImagesForEditor( + '' + ); + expect(out).toContain('data-cid="kept"'); + expect(out).not.toContain('data-cid="abc"'); + }); + + it("leaves non-cid images alone", () => { + const out = rewriteCidImagesForEditor( + '' + ); + expect(out).toContain('src="https://example.com/x.png"'); + expect(out).toContain('data-cid="y"'); + }); +}); + +describe("replaceInlineImagePlaceholders", () => { + it("returns input unchanged when the map is empty", () => { + const html = ''; + expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html); + }); + + it("swaps the placeholder src to the data URL for matching cids", () => { + const html = ``; + const out = replaceInlineImagePlaceholders( + html, + new Map([["abc", "data:image/png;base64,AAAA"]]) + ); + expect(out).toContain('src="data:image/png;base64,AAAA"'); + expect(out).toContain('data-cid="abc"'); + }); + + it("also rewrites raw cid: src refs that lack a placeholder", () => { + const html = ''; + const out = replaceInlineImagePlaceholders( + html, + new Map([["abc", "data:image/png;base64,AAAA"]]) + ); + expect(out).toContain('src="data:image/png;base64,AAAA"'); + }); + + it("does not overwrite images the user has re-pointed away from the cid", () => { + const html = + ''; + const out = replaceInlineImagePlaceholders( + html, + new Map([["abc", "data:image/png;base64,AAAA"]]) + ); + expect(out).toContain('src="https://example.com/other.png"'); + expect(out).not.toContain("data:image/png;base64,AAAA"); + }); + + it("leaves unknown cids untouched", () => { + const html = ``; + const out = replaceInlineImagePlaceholders( + html, + new Map([["abc", "data:image/png;base64,AAAA"]]) + ); + expect(out).toBe(html); + }); +}); diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts index 8141cc42..e41fdb8f 100644 --- a/lib/email-composer-utils.ts +++ b/lib/email-composer-utils.ts @@ -21,3 +21,59 @@ export function plainTextToComposerBody(text: string): string { .map((paragraph) => `

${escapeHtml(paragraph).replace(/\n/g, "
")}

`) .join(""); } + +// Transparent 1x1 GIF used as a stand-in src while the real inline image is +// being fetched from JMAP. Browsers cannot render `cid:` URLs directly, so +// without this swap the editor would show a broken-image icon (issue #163). +export const INLINE_IMAGE_PLACEHOLDER = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + +/** + * Rewrites `` references into `` + * so TipTap can render the editor (the original cid: URL would 404) while still + * carrying the cid through edits. The placeholder is swapped to the actual + * image data once the corresponding inline blob has been fetched. + */ +export function rewriteCidImagesForEditor(html: string): string { + if (!html || html.indexOf("cid:") === -1) return html; + const doc = new DOMParser().parseFromString(`${html}`, "text/html"); + let touched = false; + doc.querySelectorAll("img").forEach((img) => { + const src = img.getAttribute("src") || ""; + if (!/^cid:/i.test(src)) return; + const cid = src.slice(4); + if (!cid) return; + if (!img.getAttribute("data-cid")) { + img.setAttribute("data-cid", cid); + } + img.setAttribute("src", INLINE_IMAGE_PLACEHOLDER); + touched = true; + }); + return touched ? doc.body.innerHTML : html; +} + +/** + * Replaces the placeholder src on `` elements with the + * resolved data URL once the inline blob has been fetched. Leaves images + * whose src has been edited away from the placeholder/cid alone. + */ +export function replaceInlineImagePlaceholders( + html: string, + cidToDataUrl: Map +): string { + if (!html || cidToDataUrl.size === 0) return html; + if (html.indexOf("data-cid") === -1) return html; + const doc = new DOMParser().parseFromString(`${html}`, "text/html"); + let changed = false; + doc.querySelectorAll("img[data-cid]").forEach((img) => { + const cid = img.getAttribute("data-cid"); + if (!cid) return; + const dataUrl = cidToDataUrl.get(cid); + if (!dataUrl) return; + const currentSrc = img.getAttribute("src") || ""; + if (currentSrc !== INLINE_IMAGE_PLACEHOLDER && !/^cid:/i.test(currentSrc)) return; + img.setAttribute("src", dataUrl); + changed = true; + }); + return changed ? doc.body.innerHTML : html; +} From 2b1b06abd675d541eab5b157ccf017dd80b73bcd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 12:16:49 +0200 Subject: [PATCH 43/44] fix: collapse empty viewer pane so mail list fills the space --- app/(main)/[locale]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 1a2a2c17..c6d7fff1 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1973,7 +1973,7 @@ export default function Home() { const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent); - const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent; + const shouldHideViewerPane = !isMobile && !hasViewerContent; const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent; // Handle email selection with mobile view switching @@ -2596,7 +2596,7 @@ export default function Home() {
{/* Email list resize handle (desktop only) */} - {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && ( + {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && ( { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} From 4269d0589ca7d0713907a8823045f363b0b7ffb0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 22 May 2026 12:19:34 +0200 Subject: [PATCH 44/44] feat: collapse empty viewer pane and hide placeholder in Pro mode --- app/(main)/[locale]/page.tsx | 2 +- components/email/email-viewer.tsx | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index c6d7fff1..c09a63ea 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1973,7 +1973,7 @@ export default function Home() { const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent); - const shouldHideViewerPane = !isMobile && !hasViewerContent; + const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout); const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent; // Handle email selection with mobile view switching diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 1ad137fb..9d6444a3 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -79,6 +79,7 @@ import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { useTour } from "@/components/tour/tour-provider"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; @@ -922,6 +923,7 @@ export function EmailViewer({ const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); + const isEmbedded = useIsEmbedded(); const [showFullHeaders, setShowFullHeaders] = useState(false); const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false); const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false); @@ -3252,7 +3254,23 @@ export function EmailViewer({ ); } return ( -
+
+ {!isEmbedded && ( +
+
+ +
+

{t('no_conversation_selected')}

+

{t('no_conversation_description')}

+ {onCompose && ( + + )} +
+ )} +
); }