diff --git a/CHANGELOG.md b/CHANGELOG.md index 024db29e..358dd5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # 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 +- **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) +- **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` +- **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 +- **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/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(); +} diff --git a/app/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx similarity index 99% rename from app/[locale]/auth/callback/page.tsx rename to app/(main)/[locale]/auth/callback/page.tsx index efe701cb..a469c194 100644 --- a/app/[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/[locale]/calendar/page.tsx b/app/(main)/[locale]/calendar/page.tsx similarity index 94% rename from app/[locale]/calendar/page.tsx rename to app/(main)/[locale]/calendar/page.tsx index 138c8717..80611f3f 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/(main)/[locale]/calendar/page.tsx @@ -17,7 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useAccountStore } from "@/stores/account-store"; import { toast } from "@/stores/toast-store"; -import { useIsMobile } from "@/hooks/use-media-query"; +import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; @@ -46,6 +46,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; +import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { getEventStartDate } from "@/lib/calendar-utils"; @@ -76,7 +77,13 @@ export default function CalendarPage() { const t = useTranslations("calendar"); const tWebcalAction = useTranslations("calendar.webcal_action"); const isMobile = useIsMobile(); + const isDesktop = useIsDesktop(); const isEmbedded = useIsEmbedded(); + // When the pane (Pro shell) or window is narrower than `lg`, the sidebar + // collapses into a burger-toggled overlay instead of taking inline space. + const isNarrow = !isDesktop; + const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false); + useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); @@ -257,12 +264,17 @@ export default function CalendarPage() { return subscribeToPendingWebcal(openPendingWebcal); }, [isAuthenticated, client, handleWebcalProtocolRequest]); + // Single-account fetch path. The Pro shell aggregates calendars from + // every connected account via [[useProMultiAccountCalendars]] below, so + // skip this fetch there to avoid clobbering the merged list with the + // active client's calendars only. useEffect(() => { + if (isEmbedded) return; if (client && !hasFetched.current) { hasFetched.current = true; fetchCalendars(client); } - }, [client, fetchCalendars]); + }, [client, fetchCalendars, isEmbedded]); // Auto-refresh iCal subscriptions useEffect(() => { @@ -331,10 +343,21 @@ export default function CalendarPage() { }, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]); useEffect(() => { + if (isEmbedded) return; if (client && calendars.length > 0 && dateRange) { fetchEvents(client, dateRange.start, dateRange.end); } - }, [client, calendars.length, dateRange, fetchEvents]); + }, [client, calendars.length, dateRange, fetchEvents, isEmbedded]); + + // Pro shell only: aggregate calendars and events from every connected + // account so the sidebar lists them all (and the views render their + // events together). The hook is a no-op outside the embedded shell. + const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars( + isEmbedded ? dateRange?.start ?? null : null, + isEmbedded ? dateRange?.end ?? null : null, + ); + const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars); + const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents); const navigatePrev = useCallback(() => { let next: Date; @@ -406,6 +429,8 @@ export default function CalendarPage() { setMobileReturnToMonth(true); setViewMode("day"); } + // Close the narrow-pane sidebar overlay after the user picks a date. + setNarrowSidebarOpen(false); }, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]); const navigateBackToMonth = useCallback(() => { @@ -556,12 +581,15 @@ export default function CalendarPage() { }, [events, client]); const refetchCurrentRange = useCallback(async () => { - if (!client) return; + if (!client || !activeAccountId) return; const { dateRange: currentRange } = useCalendarStore.getState(); - if (currentRange) { - await fetchEvents(client, currentRange.start, currentRange.end); + if (!currentRange) return; + if (multiAccountEnabled && accountClients.length > 0) { + await fetchAllAccountsEventsFn(accountClients, activeAccountId, currentRange.start, currentRange.end); + return; } - }, [client, fetchEvents]); + await fetchEvents(client, currentRange.start, currentRange.end); + }, [client, fetchEvents, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsEventsFn]); // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // and refresh calendar data via JMAP instead of reloading the page. @@ -569,8 +597,11 @@ export default function CalendarPage() { enabled: isAuthenticated && !!client, onRefresh: async () => { if (!client) return; + const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId + ? fetchAllAccountsCalendarsFn(accountClients, activeAccountId) + : fetchCalendars(client); await Promise.all([ - fetchCalendars(client), + calendarRefresh, refetchCurrentRange(), refreshAllSubscriptions(client), ]); @@ -1221,7 +1252,7 @@ export default function CalendarPage() { return (
-
+
{/* Left Navigation Rail (hidden when embedded in Pro shell) */} {!isMobile && !isEmbedded && (
@@ -1242,15 +1273,31 @@ export default function CalendarPage() { )} - {/* Sidebar - full height */} - {!isMobile && !inlineApp && ( + {/* Narrow-pane backdrop: dim and close overlay sidebar */} + {isNarrow && narrowSidebarOpen && !inlineApp && ( +
setNarrowSidebarOpen(false)} + /> + )} + + {/* Sidebar - in-flow when desktop pane, overlay when narrow */} + {!inlineApp && ( <>
setShowSubscriptionModal(true)} onEditSubscription={(subId) => setEditingSubscription(subId)} client={client} + multiAccountMode={multiAccountEnabled && accountClients.length > 1} />
- { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }} - onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} - onResizeEnd={() => { - setIsResizing(false); - localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth)); - }} - onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }} - /> + {!isNarrow && ( + { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }} + onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsResizing(false); + localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth)); + }} + onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }} + /> + )} )} @@ -1343,6 +1393,7 @@ export default function CalendarPage() { selectedCalendarIds={selectedCalendarIds} onToggleVisibility={toggleCalendarVisibility} enableCalendarTasks={enableCalendarTasks} + onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined} />
(null); const [sharingAddressBookId, setSharingAddressBookId] = useState(null); const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState(undefined); + const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined); + const [returnToEmail, setReturnToEmail] = useState(false); const [renamingKeyword, setRenamingKeyword] = useState(null); const [selectedGroupId, setSelectedGroupId] = useState(null); const hasFetched = useRef(false); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const isMobile = useIsMobile(); + const isDesktop = useIsDesktop(); const isEmbedded = useIsEmbedded(); + const router = useRouter(); + const searchParams = useSearchParams(); + // One-shot intent flag: only consume the URL params on the first render that + // has them. After applying, we strip the query so a later refresh or + // re-mount doesn't re-trigger the navigation. + const intentAppliedRef = useRef(false); + // Narrow pane (Pro split or small window): the categories sidebar collapses + // into a burger-toggled overlay. + const isNarrow = !isDesktop; + const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false); + useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]); // Panel resize state - sidebar (categories) const [sidebarWidth, setSidebarWidth] = useState(() => { @@ -134,12 +151,41 @@ export default function ContactsPage() { } }, [initialCheckDone, isAuthenticated, authLoading]); + // Pro shell only: aggregate contacts and address books from every + // connected account so the sidebar lists them all. The hook is a no-op + // outside the embedded shell. + const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts(); + useEffect(() => { + if (isEmbedded) return; if (client && supportsSync && !hasFetched.current) { hasFetched.current = true; fetchContacts(client); } - }, [client, supportsSync, fetchContacts]); + }, [client, supportsSync, fetchContacts, isEmbedded]); + + // Consume one-shot URL params (set by the mobile recipient popover when no + // sidebar is available) and strip them so a refresh doesn't replay the + // intent. `from=email` flips the mobile back button to `router.back()`. + useEffect(() => { + if (intentAppliedRef.current) return; + const contactId = searchParams.get('contactId'); + const addEmail = searchParams.get('addEmail'); + const addName = searchParams.get('addName'); + const from = searchParams.get('from'); + if (!contactId && !addEmail && !from) return; + intentAppliedRef.current = true; + if (from === 'email') setReturnToEmail(true); + if (contactId) { + setSelectedContact(contactId); + setView('detail'); + } else if (addEmail) { + setCreatePrefill({ email: addEmail, name: addName ?? undefined }); + setSelectedContact(null); + setView('create'); + } + router.replace('/contacts'); + }, [searchParams, router, setSelectedContact]); // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // and refresh contacts via JMAP instead of reloading the page. @@ -147,6 +193,17 @@ export default function ContactsPage() { enabled: isAuthenticated && !!client && supportsSync, onRefresh: async () => { if (!client) return; + if (multiAccountEnabled && accountClients.length > 0) { + const activeId = useAuthStore.getState().activeAccountId; + if (activeId) { + const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState(); + await Promise.all([ + fetchAllAccountsAddressBooks(accountClients, activeId), + fetchAllAccountsContacts(accountClients, activeId), + ]); + return; + } + } await fetchContacts(client); }, }); @@ -198,6 +255,7 @@ export default function ContactsPage() { } else { setSelectedGroupId(null); } + setNarrowSidebarOpen(false); }, [clearSelection]); const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => { @@ -340,8 +398,14 @@ export default function ContactsPage() { toast.success(t("toast.created")); } setDefaultBookIdForCreate(undefined); + setCreatePrefill(undefined); + if (returnToEmail) { + setReturnToEmail(false); + router.back(); + return; + } setView("list"); - }, [supportsSync, client, createContact, addLocalContact, t]); + }, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]); const handleSaveEdit = useCallback(async (data: Partial) => { if (!selectedContact) return; @@ -358,6 +422,14 @@ export default function ContactsPage() { const handleCancel = () => { setDefaultBookIdForCreate(undefined); + // Came from email → cancel returns to the email instead of the contact list. + if (returnToEmail && view === "create") { + setCreatePrefill(undefined); + setReturnToEmail(false); + router.back(); + return; + } + if (view === "create") setCreatePrefill(undefined); if (view === "group-create" || view === "group-edit") { setView(selectedGroup ? "group-detail" : "list"); } else if (view === "bulk-add-to-group") { @@ -529,7 +601,7 @@ export default function ContactsPage() { const renderRightPanel = () => { switch (view) { case "create": - return ; + return ; case "edit": if (!selectedContact) return null; @@ -661,6 +733,12 @@ export default function ContactsPage() { const showRightPanel = !isMobile || view !== "list"; const mobileBackToList = () => { + if (returnToEmail) { + setReturnToEmail(false); + setCreatePrefill(undefined); + router.back(); + return; + } setView("list"); clearSelection(); }; @@ -689,18 +767,33 @@ export default function ContactsPage() { {inlineApp && ( )} -
+
+ {/* Narrow-pane backdrop for the overlay categories sidebar */} + {isNarrow && narrowSidebarOpen && ( +
setNarrowSidebarOpen(false)} + /> + )} {showListPanel && ( <> - {/* Panel 1: Categories sidebar */} - {!isMobile && ( + {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */} + {(!isMobile || isNarrow) && ( <>
setRenamingKeyword(kw)} + multiAccountMode={multiAccountEnabled && accountClients.length > 1} />
- { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} - onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} - onResizeEnd={() => { - setIsSidebarResizing(false); - localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); - }} - onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} - /> + {!isNarrow && ( + { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} + onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsSidebarResizing(false); + localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); + }} + onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} + /> + )} )} @@ -780,6 +876,7 @@ export default function ContactsPage() { onEditContact={handleEditContact} onDeleteContact={handleDeleteContact} onAddContactToGroup={handleAddContactToGroup} + onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined} />
@@ -809,7 +906,7 @@ export default function ContactsPage() { className="touch-manipulation" > - {t("back_to_contacts")} + {returnToEmail ? t("back_to_email") : t("back_to_contacts")}
)} 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 86% rename from app/[locale]/files/page.tsx rename to app/(main)/[locale]/files/page.tsx index 7eb2f7b1..10aa3a52 100644 --- a/app/[locale]/files/page.tsx +++ b/app/(main)/[locale]/files/page.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; +import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; import { useFileStore } from "@/stores/file-store"; import { toast } from "@/stores/toast-store"; @@ -33,6 +34,9 @@ export default function FilesPage() { const t = useTranslations("files"); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const getClientForAccount = useAuthStore((s) => s.getClientForAccount); + const accounts = useAccountStore((s) => s.accounts); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); @@ -130,13 +134,18 @@ export default function FilesPage() { } }, [initialCheckDone, isAuthenticated, authLoading]); - // Initialize JMAP files client + // 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. useEffect(() => { - if (isAuthenticated && client && !hasFetched.current) { - hasFetched.current = true; - initClient(client); + if (!isAuthenticated || !client || hasFetched.current) return; + hasFetched.current = true; + if (isEmbedded) { + useFileStore.getState().clearClient(); + } else { + initClient(client, activeAccountId); } - }, [isAuthenticated, client, initClient]); + }, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]); // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // and refresh files via JMAP instead of reloading the page. @@ -160,6 +169,17 @@ export default function FilesPage() { }, [storeClient, supportsFiles, checkSupport, navigate]); const handleNavigate = useCallback((path: string, resourceId?: string | null) => { + // Pro shell only: the Account breadcrumb segment signals "go to this + // account's filesystem root" via a sentinel, distinguishing it from a + // Home click (which detaches the account and returns to the picker). + if (resourceId === '__account_root__') { + void navigate(null); + return; + } + if (isEmbedded && path === '/' && resourceId === undefined) { + useFileStore.getState().clearClient(); + return; + } if (resourceId !== undefined) { // Direct ID-based navigation (directory click, breadcrumb dropdown folder) navigate(resourceId, path.split('/').pop() || ''); @@ -167,7 +187,7 @@ export default function FilesPage() { // Path-based navigation (breadcrumbs, favorites, recent files) navigateByPath(path); } - }, [navigate, navigateByPath]); + }, [navigate, navigateByPath, isEmbedded]); const handleCreateFolder = useCallback(async (name: string) => { try { @@ -374,6 +394,38 @@ export default function FilesPage() { setShowDetails(v => !v); }, []); + 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 + // account's files are shown until the user enters one. + const accountFolders = isEmbedded + ? accounts + .filter((a) => a.isConnected) + .map((a) => ({ + accountId: a.id, + label: a.label || a.email, + email: a.email, + avatarColor: a.avatarColor, + })) + : []; + const isAccountPicker = isEmbedded && currentFilesAccountId === null; + const currentAccountLabel = isEmbedded && currentFilesAccountId + ? (accounts.find((a) => a.id === currentFilesAccountId)?.label + || accounts.find((a) => a.id === currentFilesAccountId)?.email + || null) + : null; + + const handleSelectAccount = useCallback((accountId: string) => { + const nextClient = getClientForAccount(accountId); + if (!nextClient) return; + const store = useFileStore.getState(); + store.initClient(nextClient, accountId); + // Reset supportsFiles so the existing checkSupport effect re-runs for + // the freshly-attached client and triggers the initial navigate(null). + useFileStore.setState({ supportsFiles: null }); + }, [getClientForAccount]); + if (!isAuthenticated) return null; return ( @@ -401,7 +453,7 @@ export default function FilesPage() { )}
- {folderLayout !== "sidebar" && ( + {folderLayout !== "sidebar" && !isEmbedded && (
)} diff --git a/app/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx similarity index 93% rename from app/[locale]/layout.tsx rename to app/(main)/[locale]/layout.tsx index 238de5b7..60944ea5 100644 --- a/app/[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/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx similarity index 99% rename from app/[locale]/login/page.tsx rename to app/(main)/[locale]/login/page.tsx index a998af52..0ef6ccd7 100644 --- a/app/[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/[locale]/page.tsx b/app/(main)/[locale]/page.tsx similarity index 97% rename from app/[locale]/page.tsx rename to app/(main)/[locale]/page.tsx index a15b3635..694dc3b3 100644 --- a/app/[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"; @@ -300,8 +301,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 delayedSendSupported = client?.hasDelayedSend() ?? true; const activeEmails = isScheduledView ? scheduledEmails : emails; @@ -956,15 +965,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 @@ -1544,6 +1555,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 (mailboxId === SCHEDULED_MAILBOX_ID) { if (!delayedSendSupported) { @@ -1906,7 +1946,6 @@ export default function Home() { const handleSearch = async (query: string) => { if (!client) return; - if (isUnifiedView) return; setSearchQuery(query); if (!isFilterEmpty(searchFilters)) { await advancedSearch(client); @@ -1918,14 +1957,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); }; @@ -1935,9 +1985,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 () => { @@ -2089,7 +2139,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 && (isEmbedded || isFocusedMailLayout); const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent; // Handle email selection with mobile view switching @@ -2365,6 +2415,10 @@ export default function Home() { } }} onSidebarClose={() => setSidebarOpen(false)} + multiAccountMode={isEmbedded} + accountMailboxes={accountMailboxes} + viewingAccountId={viewingAccountId} + onAccountMailboxSelect={handleAccountMailboxSelect} />
@@ -2748,7 +2802,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)} diff --git a/app/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx similarity index 92% rename from app/[locale]/pro/page.tsx rename to app/(main)/[locale]/pro/page.tsx index 14d2bb79..c8b00f14 100644 --- a/app/[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"; @@ -16,11 +17,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"; @@ -57,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); @@ -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]); @@ -262,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). @@ -310,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. */}
= { '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', @@ -362,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 @@ -865,17 +867,19 @@ export default function SettingsPage() { )} style={{ width: `${settingsSidebarWidth}px` }} > -
- -
+ {!proInterface && ( +
+ +
+ )}
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 99% rename from app/admin/_tabs/auth.tsx rename to app/(main)/admin/_tabs/auth.tsx index b56a66f6..4387ef2e 100644 --- a/app/admin/_tabs/auth.tsx +++ b/app/(main)/admin/_tabs/auth.tsx @@ -6,7 +6,7 @@ import { apiFetch } from '@/lib/browser-navigation'; interface ConfigEntry { // Sensitive keys (sessionSecret, oauthClientSecret) come back with - // `value` omitted and `hasValue` set instead — the server never echoes + // `value` omitted and `hasValue` set instead - the server never echoes // the raw secret to the client. value?: unknown; source: 'admin' | 'env' | 'default'; @@ -271,7 +271,7 @@ export function AuthTab() { - + diff --git a/app/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx similarity index 59% rename from app/admin/_tabs/branding.tsx rename to app/(main)/admin/_tabs/branding.tsx index 2c9a4fa5..b9426697 100644 --- a/app/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/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 86% rename from app/admin/_tabs/marketplace.tsx rename to app/(main)/admin/_tabs/marketplace.tsx index af2af802..64fda763 100644 --- a/app/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/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 89% rename from app/admin/marketplace/[slug]/page.tsx rename to app/(main)/admin/marketplace/[slug]/page.tsx index 26fff536..e8095b05 100644 --- a/app/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 && ( + + )} (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". - 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(() => { - // 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 +149,7 @@ export default function SetupWizardPage() { return () => { cancelled = true; }; - }, [router, insecureContext]); + }, [router]); // ─── Token submit (welcome step) ──────────────────────────────────────── async function submitToken(token: string) { @@ -184,8 +181,8 @@ export default function SetupWizardPage() { } // ─── Render shell ─────────────────────────────────────────────────────── - if (insecureContext) { - return ; + if (insecureContext && !insecureAcknowledged) { + return setInsecureAcknowledged(true)} />; } if (bootstrapping) { @@ -362,7 +359,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 +370,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 + + )} + +
); } @@ -743,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' ? (
@@ -1782,13 +1794,52 @@ 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; // 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/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx new file mode 100644 index 00000000..9813c79d --- /dev/null +++ b/app/(sandbox)/layout.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; +import { Geist, Geist_Mono } from 'next/font/google'; +import '../globals.css'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'Plugin sandbox', + robots: { index: false, follow: false }, +}; + +export default function PluginSandboxLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} 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/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/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..d62bacf6 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), @@ -144,14 +149,16 @@ export async function GET( getPluginRegistry(), getThemeRegistry(), ]); - const installed = type === 'theme' - ? themeRegistry.themes.some((t) => 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) ? (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 +177,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( @@ -206,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 b3720bad..fe54a601 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, @@ -19,8 +21,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 +37,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()) { @@ -59,23 +66,32 @@ 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 - ? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() + ? new URL(`/api/v1/files/${path}`, directoryUrl).toString() : 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, { @@ -108,7 +124,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), }); @@ -194,22 +211,43 @@ 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, - 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'], - 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 @@ -291,17 +329,25 @@ 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, - 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', 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'] } @@ -322,9 +368,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' }); 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/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/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/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts index 8b42dbb7..54ad6c4a 100644 --- a/app/api/pwa-icon/[size]/route.ts +++ b/app/api/pwa-icon/[size]/route.ts @@ -2,11 +2,14 @@ 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'; +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) @@ -16,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); @@ -32,7 +43,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 }); } @@ -42,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); @@ -56,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) { 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/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/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/app/plugin-sandbox/layout.tsx b/app/plugin-sandbox/layout.tsx deleted file mode 100644 index 8c1f20d4..00000000 --- a/app/plugin-sandbox/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { Metadata } from 'next'; -import type { ReactNode } from 'react'; - -export const metadata: Metadata = { - title: 'Plugin sandbox', - robots: { index: false, follow: false }, -}; - -export default function PluginSandboxLayout({ children }: { children: ReactNode }) { - return ( - - - {children} - - - ); -} 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/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index e4b67207..e93ab590 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -2,19 +2,49 @@ import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; -import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react"; +import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react"; import { cn, formatDateTime } from "@/lib/utils"; import type { Calendar } from "@/lib/jmap/types"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useTaskStore } from "@/stores/task-store"; +import { useAccountStore } from "@/stores/account-store"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { toast } from "@/stores/toast-store"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; +/** + * Split a per-account calendar list into "owned" (the user's own) and + * "shared" sub-buckets, then group shared by the owning principal so each + * delegator gets its own sub-section. + */ +type AccountCalendarSplit = { + owned: Calendar[]; + sharedGroups: { label: string; calendars: Calendar[] }[]; +}; + +function splitAccountCalendars(list: Calendar[]): AccountCalendarSplit { + const owned: Calendar[] = []; + const sharedBuckets = new Map(); + for (const cal of list) { + if (cal.isShared) { + const key = cal.accountId || cal.accountName || cal.id; + const bucket = sharedBuckets.get(key); + if (bucket) { + bucket.calendars.push(cal); + } else { + sharedBuckets.set(key, { label: cal.accountName || key, calendars: [cal] }); + } + } else { + owned.push(cal); + } + } + return { owned, sharedGroups: Array.from(sharedBuckets.values()) }; +} + interface CalendarSidebarPanelProps { calendars: Calendar[]; selectedCalendarIds: string[]; @@ -28,6 +58,12 @@ interface CalendarSidebarPanelProps { onSubscribe?: () => void; onEditSubscription?: (subscriptionId: string) => void; client?: IJMAPClient | null; + /** + * When true, render one collapsible section per connected local account, + * mirroring the mail sidebar's Pro-shell layout. Calendars are bucketed + * by their `localAccountId` and the active account is shown first. + */ + multiAccountMode?: boolean; } export function CalendarSidebarPanel({ @@ -43,6 +79,7 @@ export function CalendarSidebarPanel({ onSubscribe, onEditSubscription, client, + multiAccountMode, }: CalendarSidebarPanelProps) { const t = useTranslations("calendar"); const tSub = useTranslations("calendar.subscription"); @@ -70,6 +107,26 @@ export function CalendarSidebarPanel({ const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const [refreshingSubId, setRefreshingSubId] = useState(null); + // Persisted across mounts so toggle state survives tab switches in the + // Pro shell (same key family as the mail sidebar's account collapse). + const [collapsedAccountGroups, setCollapsedAccountGroups] = useState>(() => { + try { + const raw = localStorage.getItem('calendar-sidebar-collapsed-accounts'); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { return new Set(); } + }); + const toggleAccountGroup = (key: string) => { + setCollapsedAccountGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + try { localStorage.setItem('calendar-sidebar-collapsed-accounts', JSON.stringify(Array.from(next))); } catch { /* */ } + return next; + }); + }; + + const localAccounts = useAccountStore((s) => s.accounts); + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]); const sharedAccountGroups = useMemo(() => { const shared = calendars.filter(c => c.isShared); @@ -84,6 +141,53 @@ export function CalendarSidebarPanel({ return Array.from(groups.values()); }, [calendars]); + /** + * Pro / multi-account grouping: every calendar bucketed by its owning + * local account. Active account comes first, then the rest in their + * account-store order. Calendars without a `localAccountId` (e.g. the + * birthday calendar) fall into a separate "other" bucket so they still + * render. + */ + const localAccountGroups = useMemo(() => { + if (!multiAccountMode) return []; + const byAccount = new Map(); + for (const cal of calendars) { + const key = cal.localAccountId || '__other__'; + const list = byAccount.get(key) ?? []; + list.push(cal); + byAccount.set(key, list); + } + const ordered: { key: string; label: string; split: AccountCalendarSplit }[] = []; + // Active account first. + if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) { + const acct = localAccounts.find(a => a.id === activeLocalAccountId); + ordered.push({ + key: activeLocalAccountId, + label: acct?.label || acct?.email || acct?.username || activeLocalAccountId, + split: splitAccountCalendars(byAccount.get(activeLocalAccountId)!), + }); + byAccount.delete(activeLocalAccountId); + } + // Then the rest in account-store order so the layout matches the mail sidebar. + for (const acct of localAccounts) { + if (!byAccount.has(acct.id)) continue; + ordered.push({ + key: acct.id, + label: acct.label || acct.email || acct.username, + split: splitAccountCalendars(byAccount.get(acct.id)!), + }); + byAccount.delete(acct.id); + } + // Any leftover buckets (deleted accounts, untagged calendars). + for (const [key, list] of byAccount.entries()) { + const fallbackLabel = key === '__other__' + ? t('my_calendars') + : list[0]?.accountName || key; + ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) }); + } + return ordered; + }, [multiAccountMode, calendars, localAccounts, activeLocalAccountId, t]); + const getSubscriptionForCalendar = (calendarId: string) => { return icalSubscriptions.find(s => s.calendarId === calendarId); }; @@ -268,37 +372,110 @@ export function CalendarSidebarPanel({ )} )} -
- {onCreateCalendar ? ( - - ) : ( -

- {t('my_calendars')} -

- )} -
-
- {personalCalendars.map(renderCalendarItem)} -
- - {sharedAccountGroups.map((group) => ( -
-

- - {group.accountName} -

-
- {group.calendars.map(renderCalendarItem)} + {multiAccountMode && localAccountGroups.length > 0 ? ( + <> + {localAccountGroups.map((group, idx) => { + const expanded = !collapsedAccountGroups.has(group.key); + const isActive = group.key === activeLocalAccountId; + const { owned, sharedGroups } = group.split; + return ( +
+ + {expanded && ( +
+ {owned.length > 0 && ( +
+
+ {t('my_calendars')} +
+
+ {owned.map(renderCalendarItem)} +
+
+ )} + {sharedGroups.map((sg) => ( +
+
+ + {sg.label} +
+
+ {sg.calendars.map(renderCalendarItem)} +
+
+ ))} +
+ )} +
+ ); + })} + + ) : ( + <> +
+ {onCreateCalendar ? ( + + ) : ( +

+ {t('my_calendars')} +

+ )}
-
- ))} +
+ {personalCalendars.map(renderCalendarItem)} +
+ + {sharedAccountGroups.map((group) => ( +
+

+ + {group.accountName} +

+
+ {group.calendars.map(renderCalendarItem)} +
+
+ ))} + + )} {renderCalendarMenu()}
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 89710a93..c642bd79 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect } from "react"; import { useTranslations, useFormatter } from "next-intl"; import { Button } from "@/components/ui/button"; -import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react"; import { addDays, startOfWeek } from "date-fns"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; @@ -26,6 +26,8 @@ interface CalendarToolbarProps { selectedCalendarIds?: string[]; onToggleVisibility?: (id: string) => void; enableCalendarTasks?: boolean; + /** Show a burger button at the start that opens the (overlay) sidebar. */ + onMenuClick?: () => void; } export function CalendarToolbar({ @@ -45,6 +47,7 @@ export function CalendarToolbar({ selectedCalendarIds, onToggleVisibility, enableCalendarTasks, + onMenuClick, }: CalendarToolbarProps) { const t = useTranslations("calendar"); const formatter = useFormatter(); @@ -115,11 +118,32 @@ export function CalendarToolbar({ return (
+ {/* Burger menu (rendered in pages that use a narrow overlay sidebar) */} + {onMenuClick && !isMobile && ( + + )} {/* ── MOBILE TOOLBAR ── */} {isMobile && (
{/* Row 1: Back / Date nav / Today */}
+ {onMenuClick && ( + + )} {onNavigateBack && ( + )} - {/* My Address Books */} - {personalBooks.length > 0 && ( -
-
- +
+ {expanded && ( +
+ {owned.length > 0 && ( +
+
+ {t("address_books.title")} +
+ {owned.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + /> + ))} +
+ )} + {sharedGroups.map((sg) => ( +
+
+ + {sg.label} +
+ {sg.books.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + /> + ))} +
+ ))} +
)} - - {t("address_books.title")} - - - +
+ ); + }) + ) : ( + personalBooks.length > 0 && ( +
+
+ + +
+ {!collapsed.addressBooks && personalBooks.map((book) => ( + onSelectCategory({ addressBookId: book.id })} + onDropContacts={onDropContacts} + onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} + /> + ))}
- {!collapsed.addressBooks && personalBooks.map((book) => ( - onSelectCategory({ addressBookId: book.id })} - onDropContacts={onDropContacts} - onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined} - /> - ))} -
+ ) )} {/* Groups section */} @@ -398,8 +541,9 @@ export function ContactsSidebar({ )}
- {/* 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) => (
+ )}
-

{t('no_conversation_selected')}

-

{t('no_conversation_description')}

- {onCompose && ( - - )} -
+ )}
); } 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/components/files/file-browser.tsx b/components/files/file-browser.tsx index 1291c4a0..8399a612 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -11,7 +11,9 @@ import { AlertCircle, Star, Clock, FolderUp, FileArchive, FileSpreadsheet, Presentation, FileCode, Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon, + Menu, } from "lucide-react"; +import { useIsDesktop } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; import { cn, formatFileSize } from "@/lib/utils"; import { NewFolderDialog } from "@/components/files/new-folder-dialog"; @@ -21,6 +23,7 @@ import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog"; import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar"; import { ResizeHandle } from "@/components/layout/resize-handle"; +import { Avatar } from "@/components/ui/avatar"; import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; import type { FileResource } from "@/stores/file-store"; @@ -35,6 +38,13 @@ interface ClipboardState { sourceParentId: string | null; } +export interface AccountFolderEntry { + accountId: string; + label: string; + email: string; + avatarColor: string; +} + interface FileBrowserProps { currentPath: string; resources: FileResource[]; @@ -78,6 +88,13 @@ interface FileBrowserProps { showDetails: boolean; onToggleDetails: () => void; detailResource: FileResource | null; + /** 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. */ + accountPickerMode?: boolean; + /** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */ + accountLabel?: string | null; } const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]); @@ -321,6 +338,10 @@ export function FileBrowser({ onToggleDetails, detailResource, clipboard, + accountFolders, + onSelectAccount, + accountPickerMode, + accountLabel, }: FileBrowserProps) { const t = useTranslations("files"); const [showNewFolder, setShowNewFolder] = useState(false); @@ -352,6 +373,13 @@ export function FileBrowser({ const [isResizing, setIsResizing] = useState(false); const dragStartWidth = useRef(256); const [dragTarget, setDragTarget] = useState(null); + // Pane-aware: in a Pro split pane (or a narrow window) the folder tree + // sidebar collapses into a burger-toggled overlay so it doesn't crowd the + // file list. + const isDesktopPane = useIsDesktop(); + const isNarrow = !isDesktopPane; + const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false); + useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]); // Sync showThumbnails and folderLayout when settings change useEffect(() => { @@ -442,8 +470,10 @@ export function FileBrowser({ return sorted; }, [resources, searchQuery, sortKey, sortDir, folderLayout]); - // Build breadcrumb segments - const breadcrumbs = currentPath === '/' + // Build breadcrumb segments. In Pro mode an account is mounted "between" + // 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: '/' }] : [ { name: t("breadcrumb_root"), path: '/' }, @@ -452,14 +482,24 @@ export function FileBrowser({ path: '/' + arr.slice(0, i + 1).join('/'), })), ]; + if (accountLabel) { + breadcrumbs.splice(1, 0, { name: accountLabel, path: '', isAccount: true }); + } const handleNavigateUp = useCallback(() => { if (currentPath === '/') return; const segments = currentPath.split('/').filter(Boolean); segments.pop(); const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/'); - onNavigate(parentPath, null); - }, [currentPath, onNavigate]); + // Pro shell: going up to root from a subfolder must land on the + // account's filesystem root, not detach back to the account picker. + // Home click (breadcrumb) still detaches. + if (parentPath === '/' && accountLabel) { + onNavigate('/', '__account_root__'); + return; + } + onNavigate(parentPath); + }, [currentPath, onNavigate, accountLabel]); const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => { if (resource.isDirectory) { @@ -846,14 +886,27 @@ export function FileBrowser({ > {/* Toolbar */}
+ {isNarrow && folderLayout === "sidebar" && ( + + )} {/* 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 ( )} -
- - {((foldersExpanded && !isCollapsed) || isCollapsed) && ( - <> - {mailboxes.length === 0 ? ( -
- {!isCollapsed && t("loading_mailboxes")} -
- ) : ( - <> - {ownTree.map((node) => ( - - ))} - {showScheduledMailbox && ( - } - label={t('scheduled')} - depth={0} - isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} - total={scheduledTotal} - onClick={() => onMailboxSelect?.('__scheduled__')} - isCollapsed={isCollapsed} - /> - )} - - )} - - )} -
+ {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} + /> + ))} + {isActive && showScheduledMailbox && ( + } + label={t('scheduled')} + depth={0} + isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} + total={scheduledTotal} + onClick={() => onMailboxSelect?.('__scheduled__')} + isCollapsed={isCollapsed} + /> + )} + + )} + + )} +
+ ); + }) + ) : ( +
+ + {((foldersExpanded && !isCollapsed) || isCollapsed) && ( + <> + {mailboxes.length === 0 ? ( +
+ {!isCollapsed && t("loading_mailboxes")} +
+ ) : ( + <> + {ownTree.map((node) => ( + + ))} + {showScheduledMailbox && ( + } + label={t('scheduled')} + depth={0} + isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} + total={scheduledTotal} + onClick={() => onMailboxSelect?.('__scheduled__')} + isCollapsed={isCollapsed} + /> + )} + + )} + + )} +
+ )} - {sharedAccounts.length > 0 && ( + {!useMultiAccount && sharedAccounts.length > 0 && (
` 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 new file mode 100644 index 00000000..132856fe --- /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/providers/embedded-bridge-provider.tsx b/components/providers/embedded-bridge-provider.tsx index 6a1d4060..67bdb75b 100644 --- a/components/providers/embedded-bridge-provider.tsx +++ b/components/providers/embedded-bridge-provider.tsx @@ -12,7 +12,7 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode useEffect(() => { 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/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/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/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)} + />
); diff --git a/components/settings/share-collection-dialog.tsx b/components/settings/share-collection-dialog.tsx index e05ea947..7a3b9470 100644 --- a/components/settings/share-collection-dialog.tsx +++ b/components/settings/share-collection-dialog.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { Avatar } from "@/components/ui/avatar"; import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types"; @@ -230,6 +231,12 @@ export function ShareCollectionDialog({ : detectAddressBookPreset(rights as AddressBookRights); return (
  • +
    {principal?.name || principal?.email || principalId} @@ -314,6 +321,12 @@ export function ShareCollectionDialog({ className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors" >
    +
    {p.name} 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-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 218545d0..32026c7f 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -1,13 +1,47 @@ "use client"; import { useCallback, useState, DragEvent } from "react"; -import { Mailbox } from "@/lib/jmap/types"; +import { Mailbox, Email } from "@/lib/jmap/types"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { toast } from "@/stores/toast-store"; import { getMailboxPath } from "@/lib/utils"; +/** + * Returns the source accountId for an email being dragged. In unified view + * each email carries its own `accountId`; otherwise everything in the view + * belongs to whichever account is currently being viewed (Pro shell's + * Thunderbird-style sidebar) or the globally-active account. + */ +function resolveSourceAccountId(email: Email | undefined): string | null { + if (email?.accountId) return email.accountId; + const viewingId = useEmailStore.getState().viewingAccountId; + if (viewingId) return viewingId; + 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; @@ -31,7 +65,7 @@ interface UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { const [isOver, setIsOver] = useState(false); const { client } = useAuthStore(); - const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); + const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); // Determine if this is a valid drop target @@ -47,13 +81,13 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: // Virtual nodes (shared folder headers) cannot be drop targets if (mailbox.id.startsWith("shared-")) return false; - // For shared mailboxes, check account compatibility + // Shared (delegated) mailboxes still require the source to belong to the + // same delegating account. Real cross-account moves between primary + // accounts go through the cross-account path further down, but the + // shared-folder semantics here are about ACLs rather than transport, so + // they remain disallowed. if (mailbox.isShared && draggedEmails[0]) { - // Get the source mailbox's account ID from the store - const mailboxes = useEmailStore.getState().mailboxes; - const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId); - - // Cross-account moves are not supported + const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId); if (sourceMb?.accountId !== mailbox.accountId) { return false; } @@ -107,16 +141,48 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: const emailIds: string[] = JSON.parse(emailIdsJson); - // Move in a single bulk JMAP request (store handles counter updates). - await moveEmailsToMailbox(client, emailIds, mailbox.id); + // 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 = resolveDestAccountId(mailbox); + const idToEmail = new Map(draggedEmails.map((em) => [em.id, em])); + const bySource = new Map(); + for (const id of emailIds) { + const srcAccountId = resolveSourceAccountId(idToEmail.get(id)); + if (!srcAccountId) continue; + if (!bySource.has(srcAccountId)) bySource.set(srcAccountId, []); + bySource.get(srcAccountId)!.push(id); + } + + const sourceAccountIds = Array.from(bySource.keys()); + const isCrossAccount = + !!destAccountId && + !mailbox.isShared && + sourceAccountIds.some((src) => src !== destAccountId); + + if (isCrossAccount) { + // JMAP can't natively move an email between primary accounts, so the + // store reuploads each source blob into the destination account and + // then deletes the original. + const jmapDestId = mailbox.originalId || mailbox.id; + await crossAccountMoveEmails(bySource, destAccountId, jmapDestId); + } else { + // Single-account or same-account-shared move: bulk JMAP request. + await moveEmailsToMailbox(client, emailIds, mailbox.id); + } // Clear selection if any selected emails were moved if (emailIds.some(id => selectedEmailIds.has(id))) { clearSelection(); } - // Refresh the current mailbox view (honors active search/filters) - await refreshCurrentMailbox(client); + // Refresh the current mailbox view (honors active search/filters). + // Skip for cross-account moves: the store already dropped the moved + // rows from the in-memory list and refreshed both accounts' folder + // caches in the background. + if (!isCrossAccount) { + await refreshCurrentMailbox(client); + } const mailboxPath = getMailboxPath(mailbox, mailboxes); @@ -144,7 +210,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: } finally { endDrag(); } - }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); + }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); const valid = isValidTarget(); 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 new file mode 100644 index 00000000..7bc225ad --- /dev/null +++ b/hooks/use-pro-multi-account-calendars.ts @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useCalendarStore, type CalendarAccountClient } from "@/stores/calendar-store"; +import { useSettingsStore } from "@/stores/settings-store"; +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 + * same way [[use-pro-multi-account-mailboxes]] does for mail folders. + * + * Returns the resolved list of `{ localAccountId, client }` pairs so the + * caller (calendar page) can fetch events the same way without + * re-deriving the set. + */ +export function useProMultiAccountCalendars(start: string | null, end: string | null): { + enabled: boolean; + accountClients: CalendarAccountClient[]; +} { + const isEmbedded = useIsEmbedded(); + const proInterface = useSettingsStore((s) => s.proInterface); + const accounts = useAccountStore((s) => s.accounts); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const fetchAllAccountsCalendars = useCalendarStore((s) => s.fetchAllAccountsCalendars); + const fetchAllAccountsEvents = useCalendarStore((s) => s.fetchAllAccountsEvents); + + const enabled = proInterface || isEmbedded; + + const accountClients = useMemo(() => { + if (!enabled) return []; + const getClientForAccount = useAuthStore.getState().getClientForAccount; + const pairs: CalendarAccountClient[] = []; + for (const account of accounts) { + if (!account.isConnected) continue; + const client = getClientForAccount(account.id); + if (!client || !client.supportsCalendars()) continue; + pairs.push({ localAccountId: account.id, client }); + } + return pairs; + // accounts identity changes whenever the connected set or login states + // change, so this is the only dependency we need. + }, [enabled, accounts]); + + // Fetch calendars whenever the set of connected calendar-capable accounts + // changes. Skips when there isn't an active account yet (auth still + // bootstrapping). + useEffect(() => { + if (!enabled || !activeAccountId || accountClients.length === 0) return; + void fetchAllAccountsCalendars(accountClients, activeAccountId); + }, [enabled, activeAccountId, accountClients, fetchAllAccountsCalendars]); + + // Fetch events for the current visible date range across all accounts. + useEffect(() => { + if (!enabled || !activeAccountId || accountClients.length === 0) return; + if (!start || !end) return; + void fetchAllAccountsEvents(accountClients, activeAccountId, start, end); + }, [enabled, activeAccountId, accountClients, start, end, fetchAllAccountsEvents]); + + return { enabled, accountClients }; +} diff --git a/hooks/use-pro-multi-account-contacts.ts b/hooks/use-pro-multi-account-contacts.ts new file mode 100644 index 00000000..a69b7c0d --- /dev/null +++ b/hooks/use-pro-multi-account-contacts.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useContactStore, type ContactAccountClient } from "@/stores/contact-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; + +/** + * 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. + */ +export function useProMultiAccountContacts(): { + enabled: boolean; + accountClients: ContactAccountClient[]; +} { + const isEmbedded = useIsEmbedded(); + const proInterface = useSettingsStore((s) => s.proInterface); + const accounts = useAccountStore((s) => s.accounts); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const fetchAllAccountsAddressBooks = useContactStore((s) => s.fetchAllAccountsAddressBooks); + const fetchAllAccountsContacts = useContactStore((s) => s.fetchAllAccountsContacts); + + const enabled = proInterface || isEmbedded; + + const accountClients = useMemo(() => { + if (!enabled) return []; + const getClientForAccount = useAuthStore.getState().getClientForAccount; + const pairs: ContactAccountClient[] = []; + for (const account of accounts) { + if (!account.isConnected) continue; + const client = getClientForAccount(account.id); + if (!client || !client.supportsContacts()) continue; + pairs.push({ localAccountId: account.id, client }); + } + return pairs; + }, [enabled, accounts]); + + useEffect(() => { + if (!enabled || !activeAccountId || accountClients.length === 0) return; + void fetchAllAccountsAddressBooks(accountClients, activeAccountId); + void fetchAllAccountsContacts(accountClients, activeAccountId); + }, [enabled, activeAccountId, accountClients, fetchAllAccountsAddressBooks, fetchAllAccountsContacts]); + + return { enabled, accountClients }; +} diff --git a/hooks/use-pro-multi-account-identities.ts b/hooks/use-pro-multi-account-identities.ts new file mode 100644 index 00000000..786b7796 --- /dev/null +++ b/hooks/use-pro-multi-account-identities.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useIdentityStore } from "@/stores/identity-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; +import type { Identity } from "@/lib/jmap/types"; + +interface AccountIdentityGroup { + localAccountId: string; + accountLabel: string; + identities: Identity[]; +} + +const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::'; + +/** Cross-account identity IDs are namespaced to avoid collisions between + * JMAP servers that happen to issue the same opaque ID. The active + * account's IDs are left untouched so existing single-account code paths + * (reply-identity resolution, S/MIME bindings) keep working unchanged. + */ +export function isCrossAccountIdentityId(id: string): boolean { + return id.includes(CROSS_ACCOUNT_IDENTITY_DELIMITER); +} + +export function stripCrossAccountIdentityPrefix(id: string): { localAccountId: string | null; rawId: string } { + const idx = id.indexOf(CROSS_ACCOUNT_IDENTITY_DELIMITER); + if (idx < 0) return { localAccountId: null, rawId: id }; + return { + localAccountId: id.slice(0, idx), + rawId: id.slice(idx + CROSS_ACCOUNT_IDENTITY_DELIMITER.length), + }; +} + +/** + * 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 + * [[useProMultiAccountContacts]]. + * + * Outside Pro / embedded mode the hook returns `enabled: false` and the + * caller falls back to the active account's identities from + * [[useIdentityStore]]. + */ +export function useProMultiAccountIdentities(): { + enabled: boolean; + groups: AccountIdentityGroup[]; + /** Flat list across all accounts, useful for lookup-by-id. */ + allIdentities: Identity[]; +} { + const isEmbedded = useIsEmbedded(); + const proInterface = useSettingsStore((s) => s.proInterface); + const accounts = useAccountStore((s) => s.accounts); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const activeIdentities = useIdentityStore((s) => s.identities); + + const enabled = (proInterface || isEmbedded) && accounts.filter(a => a.isConnected).length > 1; + + const [remoteIdentities, setRemoteIdentities] = useState>({}); + + // Cache identities fetched per non-active account. Active account's + // identities come live from useIdentityStore so signature/alias edits + // there are reflected immediately without an extra round-trip. + useEffect(() => { + if (!enabled) { + setRemoteIdentities({}); + return; + } + let cancelled = false; + const getClientForAccount = useAuthStore.getState().getClientForAccount; + (async () => { + const next: Record = {}; + await Promise.all( + accounts + .filter((a) => a.isConnected && a.id !== activeAccountId) + .map(async (account) => { + const client = getClientForAccount(account.id); + if (!client) return; + try { + const list = await client.getIdentities(); + if (!cancelled) next[account.id] = list; + } catch { + // Skip accounts that fail to load identities - one bad + // account shouldn't blank the whole dropdown. + } + }), + ); + if (!cancelled) setRemoteIdentities(next); + })(); + return () => { cancelled = true; }; + }, [enabled, accounts, activeAccountId]); + + const groups = useMemo(() => { + if (!enabled) return []; + const out: AccountIdentityGroup[] = []; + if (activeAccountId) { + const active = accounts.find((a) => a.id === activeAccountId); + const label = active?.label || active?.email || active?.username || activeAccountId; + out.push({ + localAccountId: activeAccountId, + accountLabel: label, + identities: activeIdentities.map((id) => ({ + ...id, + localAccountId: activeAccountId, + accountName: label, + })), + }); + } + for (const account of accounts) { + if (!account.isConnected || account.id === activeAccountId) continue; + const list = remoteIdentities[account.id]; + if (!list || list.length === 0) continue; + const label = account.label || account.email || account.username; + out.push({ + localAccountId: account.id, + accountLabel: label, + identities: list.map((id) => ({ + ...id, + id: `${account.id}${CROSS_ACCOUNT_IDENTITY_DELIMITER}${id.id}`, + localAccountId: account.id, + accountName: label, + })), + }); + } + return out; + }, [enabled, accounts, activeAccountId, activeIdentities, remoteIdentities]); + + const allIdentities = useMemo(() => groups.flatMap((g) => g.identities), [groups]); + + return { enabled, groups, allIdentities }; +} 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/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/__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/__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', () => { 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 `

    ${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; +} diff --git a/lib/impersonation/jwt.ts b/lib/impersonation/jwt.ts index c1462899..4145ce13 100644 --- a/lib/impersonation/jwt.ts +++ b/lib/impersonation/jwt.ts @@ -82,7 +82,7 @@ export function verifyImpersonationJwt( } const [headerB64, payloadB64, sigB64] = parts; - // Header — reject anything but HS256 BEFORE attempting signature verification. + // Header - reject anything but HS256 BEFORE attempting signature verification. const header = parseSegment(headerB64) as 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 d43eea46..3eee60c4 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -185,6 +185,12 @@ export interface Identity { textSignature?: string; htmlSignature?: string; mayDelete: boolean; + // 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. + localAccountId?: string; + accountName?: string; } // RFC 9553 JSContact / RFC 9610 JMAP for Contacts @@ -198,6 +204,9 @@ export interface ContactCard { accountId?: string; accountName?: string; isShared?: boolean; + // Local account-store ID - set when the Pro shell aggregates contacts + // from multiple connected accounts. See `Calendar.localAccountId`. + localAccountId?: string; language?: string; name?: ContactName; nicknames?: Record; @@ -398,6 +407,8 @@ export interface AddressBook { accountId?: string; accountName?: string; isShared?: boolean; + // See `Calendar.localAccountId` - same purpose for address books. + localAccountId?: string; } export interface AddressBookRights { @@ -472,6 +483,11 @@ export interface Calendar { accountId?: string; accountName?: string; isShared?: boolean; + // Local account-store ID (per JMAP server connection). Populated when the + // Pro shell aggregates calendars from multiple connected accounts so we + // can route mutations to the right client. Distinct from `accountId` + // which is the JMAP server's own account UUID. + localAccountId?: string; } export interface CalendarRights { @@ -493,6 +509,8 @@ export interface CalendarEvent { accountId?: string; accountName?: string; isShared?: boolean; + // See `Calendar.localAccountId` - same purpose for events. + localAccountId?: string; isDraft: boolean; isOrigin: boolean; utcStart: string | null; 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 365ce1de..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); @@ -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'; @@ -153,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, '*'); } @@ -228,6 +236,10 @@ export class SandboxInstance { } case 'slot-resize': + // 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`; this.slotResizeCb?.(msg.height); return; } 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 }>(); @@ -178,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 @@ -334,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 { @@ -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/lib/setup/session.ts b/lib/setup/session.ts index e4922e4e..46938d58 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:'; +} diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index 61e1732a..f1f38649 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -117,6 +117,99 @@ export async function fetchUnifiedEmails( }; } +/** + * Runs a text search across every account that has a mailbox for the given + * unified role, merging and sorting the results by receivedAt descending. The + * fan-out / error-collection shape mirrors `fetchUnifiedEmails` so the caller + * sees consistent behavior between browse and search. + */ +export async function searchUnifiedEmails( + accounts: UnifiedAccountClient[], + role: UnifiedMailboxRole, + query: string, + limit: number, + position: number, +): Promise { + return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => { + return account.client.searchEmails(query, mailbox.id, undefined, limit, position); + }); +} + +/** + * Like `searchUnifiedEmails`, but uses the JMAP advanced filter shape. The + * caller supplies a `filterFor(mailboxId)` factory because each account's role + * mailbox has a different id and the filter must include the right + * `inMailbox` clause per request. + */ +export async function advancedSearchUnifiedEmails( + accounts: UnifiedAccountClient[], + role: UnifiedMailboxRole, + filterFor: (mailboxId: string) => Record, + limit: number, + position: number, +): Promise { + return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => { + return account.client.advancedSearchEmails(filterFor(mailbox.id), undefined, limit, position); + }); +} + +async function fanOutUnifiedQuery( + accounts: UnifiedAccountClient[], + role: UnifiedMailboxRole, + run: ( + account: UnifiedAccountClient, + mailbox: Mailbox, + ) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>, +): Promise { + const errors = new Map(); + + type AccountResult = { + account: UnifiedAccountClient; + result: { emails: Email[]; total: number; hasMore: boolean }; + } | null; + + const promises = accounts.map(async (account): Promise => { + const mailbox = findMailboxByRole(account.mailboxes, role); + if (!mailbox) return null; + try { + const result = await run(account, mailbox); + return { account, result }; + } catch (err) { + errors.set( + account.accountId, + err instanceof Error ? err.message : String(err), + ); + return null; + } + }); + + const results = await Promise.allSettled(promises); + + let mergedEmails: Email[] = []; + let totalSum = 0; + let anyHasMore = false; + + for (const outcome of results) { + if (outcome.status !== 'fulfilled' || outcome.value === null) continue; + const { account, result } = outcome.value; + for (const email of result.emails) { + email.accountId = account.accountId; + email.accountLabel = account.accountLabel; + } + mergedEmails = mergedEmails.concat(result.emails); + totalSum += result.total; + if (result.hasMore) anyHasMore = true; + } + + mergedEmails.sort((a, b) => { + const dateA = new Date(a.receivedAt).getTime(); + const dateB = new Date(b.receivedAt).getTime(); + return dateB - dateA; + }); + + return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors }; +} + /** * Aggregates unread and total email counts across all accounts for each * unified mailbox role. Only includes roles that exist in at least one account. 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/locales/cs/common.json b/locales/cs/common.json index 1f662ca3..903a4a60 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1238,6 +1238,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": { @@ -1995,6 +2007,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" @@ -2291,7 +2304,8 @@ "has_email": "Má e-mail", "has_phone": "Má telefon", "has_photo": "Má fotku" - } + }, + "open_categories": "Otevřít kategorie" }, "calendar": { "title": "Kalendář", @@ -2663,7 +2677,8 @@ "oct": "říj", "nov": "lis", "dec": "pro" - } + }, + "nav_open_menu": "Otevřít nabídku" }, "advanced_search": { "title": "Pokročilé hledání", @@ -2818,7 +2833,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 2f1b4a93..095a4240 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": { @@ -1952,6 +1964,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" @@ -2248,7 +2261,8 @@ "has_email": "Har e-mail", "has_phone": "Har telefon", "has_photo": "Har billede" - } + }, + "open_categories": "Åbn kategorier" }, "calendar": { "title": "Kalender", @@ -2620,7 +2634,8 @@ "due_today": "I dag", "due_tomorrow": "I morgen", "overdue": "Forfalden" - } + }, + "nav_open_menu": "Åbn menu" }, "sharing": { "title": "Del \"{name}\"", @@ -2798,7 +2813,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 2e8100a1..854e1b5a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1238,6 +1238,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": { @@ -1995,6 +2007,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" @@ -2291,7 +2304,8 @@ "has_email": "Mit E-Mail", "has_phone": "Mit Telefon", "has_photo": "Mit Foto" - } + }, + "open_categories": "Kategorien öffnen" }, "calendar": { "title": "Kalender", @@ -2663,7 +2677,8 @@ "oct": "Okt", "nov": "Nov", "dec": "Dez" - } + }, + "nav_open_menu": "Menü öffnen" }, "advanced_search": { "title": "Erweiterte Suche", @@ -2818,7 +2833,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/en/common.json b/locales/en/common.json index 9a6c8a15..13361fb0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1239,6 +1239,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": { @@ -1993,6 +2005,8 @@ "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", "groups": "Groups" @@ -2516,6 +2530,7 @@ }, "nav_prev": "Previous", "nav_next": "Next", + "nav_open_menu": "Open menu", "import": { "title": "Import Calendar", "tab_file": "File", @@ -2760,6 +2775,8 @@ "file": "File", "parent_directory": "Parent directory", "breadcrumb_root": "Home", + "other_accounts": "Other accounts", + "no_accounts": "No connected accounts.", "drop_files_here": "Drop files or folders here to upload", "uploading": "Uploading...", "upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}", @@ -2811,6 +2828,7 @@ "undo_success": "Action undone", "undo_error": "Failed to undo", "toolbar": "File actions", + "open_folder_tree": "Open folder tree", "file_list": "Files and folders", "context_menu": "Actions", "settings_title": "File Settings", diff --git a/locales/es/common.json b/locales/es/common.json index 24f048fb..2e3e2a7f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1238,6 +1238,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": { @@ -1995,6 +2007,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" @@ -2291,7 +2304,8 @@ "has_email": "Con correo", "has_phone": "Con teléfono", "has_photo": "Con foto" - } + }, + "open_categories": "Abrir categorías" }, "calendar": { "title": "Calendario", @@ -2663,7 +2677,8 @@ "oct": "Oct", "nov": "Nov", "dec": "Dic" - } + }, + "nav_open_menu": "Abrir menú" }, "advanced_search": { "title": "Búsqueda avanzada", @@ -2818,7 +2833,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 8950332d..87c787d6 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1238,6 +1238,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": { @@ -1995,6 +2007,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" @@ -2291,7 +2304,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", @@ -2663,7 +2677,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", @@ -2818,7 +2833,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 046786f3..052e3816 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1238,6 +1238,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": { @@ -1995,6 +2007,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" @@ -2291,7 +2304,8 @@ "has_email": "Con email", "has_phone": "Con telefono", "has_photo": "Con foto" - } + }, + "open_categories": "Apri categorie" }, "calendar": { "title": "Calendario", @@ -2663,7 +2677,8 @@ "oct": "ott", "nov": "nov", "dec": "dic" - } + }, + "nav_open_menu": "Apri menu" }, "advanced_search": { "title": "Ricerca avanzata", @@ -2818,7 +2833,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 cf0e3829..096d2a71 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1238,6 +1238,18 @@ "last_sync": { "label": "最終同期", "value": "{time}" + }, + "accounts": { + "title": "ログイン中のアカウント", + "description": "ドラッグしてアカウントメニューに表示される順序を変更", + "active": "現在アクティブなアカウント", + "default_badge": "デフォルトアカウント", + "set_default": "デフォルトに設定", + "switch_to": "このアカウントに切り替え", + "move_up": "上に移動", + "move_down": "下に移動", + "drag_handle": "ドラッグして並べ替え", + "add": "アカウントを追加" } }, "security": { @@ -1995,6 +2007,7 @@ "delete_confirm": "この連絡先を削除してもよろしいですか?", "local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)", "back_to_contacts": "連絡先に戻る", + "back_to_email": "メールに戻る", "tabs": { "all": "すべて", "groups": "グループ" @@ -2291,7 +2304,8 @@ "has_email": "メールあり", "has_phone": "電話あり", "has_photo": "写真あり" - } + }, + "open_categories": "カテゴリを開く" }, "calendar": { "title": "カレンダー", @@ -2663,7 +2677,8 @@ "oct": "10月", "nov": "11月", "dec": "12月" - } + }, + "nav_open_menu": "メニューを開く" }, "advanced_search": { "title": "詳細検索", @@ -2818,7 +2833,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 0a8afb4d..daee47fe 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1238,6 +1238,18 @@ "last_sync": { "label": "마지막 동기화", "value": "{time}" + }, + "accounts": { + "title": "로그인된 계정", + "description": "끌어서 계정 드롭다운에 표시되는 순서를 변경합니다", + "active": "현재 활성 계정", + "default_badge": "기본 계정", + "set_default": "기본으로 설정", + "switch_to": "이 계정으로 전환", + "move_up": "위로 이동", + "move_down": "아래로 이동", + "drag_handle": "끌어서 순서 변경", + "add": "계정 추가" } }, "security": { @@ -1995,6 +2007,7 @@ "delete_confirm": "정말 이 연락처를 삭제할까요?", "local_mode": "연락처가 로컬에 저장돼요 (서버가 JMAP Contacts를 지원하지 않아요)", "back_to_contacts": "연락처로 돌아가기", + "back_to_email": "이메일로 돌아가기", "tabs": { "all": "전체", "groups": "그룹" @@ -2291,7 +2304,8 @@ "has_email": "이메일 있음", "has_phone": "전화번호 있음", "has_photo": "사진 있음" - } + }, + "open_categories": "카테고리 열기" }, "calendar": { "title": "캘린더", @@ -2663,7 +2677,8 @@ "oct": "10월", "nov": "11월", "dec": "12월" - } + }, + "nav_open_menu": "메뉴 열기" }, "advanced_search": { "title": "상세 검색", @@ -2818,7 +2833,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 392e29d9..1c751e4e 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1238,6 +1238,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": { @@ -1991,6 +2003,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" @@ -2291,7 +2304,8 @@ "has_email": "Ar e-pastu", "has_phone": "Ar tālruni", "has_photo": "Ar foto" - } + }, + "open_categories": "Atvērt kategorijas" }, "calendar": { "title": "Kalendārs", @@ -2663,7 +2677,8 @@ "oct": "okt.", "nov": "nov.", "dec": "dec." - } + }, + "nav_open_menu": "Atvērt izvēlni" }, "advanced_search": { "title": "Izvērstā meklēšana", @@ -2818,7 +2833,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 84d3583a..cb716684 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1238,6 +1238,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": { @@ -1995,6 +2007,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" @@ -2291,7 +2304,8 @@ "has_email": "Met e-mail", "has_phone": "Met telefoon", "has_photo": "Met foto" - } + }, + "open_categories": "Categorieën openen" }, "calendar": { "title": "Agenda", @@ -2663,7 +2677,8 @@ "oct": "okt", "nov": "nov", "dec": "dec" - } + }, + "nav_open_menu": "Menu openen" }, "advanced_search": { "title": "Geavanceerd zoeken", @@ -2818,7 +2833,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 11deb2b8..a2829bc1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1238,6 +1238,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": { @@ -1995,6 +2007,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" @@ -2291,7 +2304,8 @@ "has_email": "Z e-mailem", "has_phone": "Z telefonem", "has_photo": "Ze zdjęciem" - } + }, + "open_categories": "Otwórz kategorie" }, "calendar": { "title": "Kalendarz", @@ -2663,7 +2677,8 @@ "oct": "paź", "nov": "lis", "dec": "gru" - } + }, + "nav_open_menu": "Otwórz menu" }, "advanced_search": { "title": "Wyszukiwanie zaawansowane", @@ -2818,7 +2833,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 4b83f1be..92f745b2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1238,6 +1238,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": { @@ -1995,6 +2007,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" @@ -2291,7 +2304,8 @@ "has_email": "Com e-mail", "has_phone": "Com telefone", "has_photo": "Com foto" - } + }, + "open_categories": "Abrir categorias" }, "calendar": { "title": "Calendário", @@ -2663,7 +2677,8 @@ "due_today": "Vence hoje", "due_tomorrow": "Vence amanhã", "overdue": "Atrasada" - } + }, + "nav_open_menu": "Abrir menu" }, "advanced_search": { "title": "Pesquisa avançada", @@ -2818,7 +2833,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 c88a7988..9a5a15f5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1238,6 +1238,18 @@ "last_sync": { "label": "Последняя синхронизация", "value": "{time}" + }, + "accounts": { + "title": "Подключённые учётные записи", + "description": "Перетащите, чтобы изменить порядок отображения учётных записей в меню", + "active": "Текущая активная учётная запись", + "default_badge": "По умолчанию", + "set_default": "Сделать основной", + "switch_to": "Переключиться на эту учётную запись", + "move_up": "Переместить вверх", + "move_down": "Переместить вниз", + "drag_handle": "Перетащите, чтобы изменить порядок", + "add": "Добавить учётную запись" } }, "security": { @@ -1995,6 +2007,7 @@ "delete_confirm": "Вы уверены, что хотите удалить этот контакт?", "local_mode": "Контакты хранятся локально (сервер не поддерживает JMAP Contacts)", "back_to_contacts": "Вернуться к контактам", + "back_to_email": "Вернуться к письму", "tabs": { "all": "Все", "groups": "Группы" @@ -2291,7 +2304,8 @@ "has_email": "С эл. почтой", "has_phone": "С телефоном", "has_photo": "С фото" - } + }, + "open_categories": "Открыть категории" }, "calendar": { "title": "Календарь", @@ -2663,7 +2677,8 @@ "oct": "окт.", "nov": "нояб.", "dec": "дек." - } + }, + "nav_open_menu": "Открыть меню" }, "advanced_search": { "title": "Расширенный поиск", @@ -2818,7 +2833,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 3591407a..5e2ef62b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1238,6 +1238,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": { @@ -1995,6 +2007,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" @@ -2291,7 +2304,8 @@ "has_email": "E-postası var", "has_phone": "Telefonu var", "has_photo": "Fotoğrafı var" - } + }, + "open_categories": "Kategorileri aç" }, "calendar": { "title": "Takvim", @@ -2663,7 +2677,8 @@ "due_today": "Bugün", "due_tomorrow": "Yarın", "overdue": "Gecikmiş" - } + }, + "nav_open_menu": "Menüyü aç" }, "sharing": { "title": "\"{name}\" paylaş", @@ -2841,7 +2856,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 ceada5db..b26e65a9 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1238,6 +1238,18 @@ "last_sync": { "label": "Остання синхронізація", "value": "{time}" + }, + "accounts": { + "title": "Підключені облікові записи", + "description": "Перетягніть, щоб змінити порядок відображення облікових записів у меню", + "active": "Поточний активний обліковий запис", + "default_badge": "Обліковий запис за замовчуванням", + "set_default": "Зробити основним", + "switch_to": "Перемкнутися на цей обліковий запис", + "move_up": "Перемістити вгору", + "move_down": "Перемістити вниз", + "drag_handle": "Перетягніть, щоб змінити порядок", + "add": "Додати обліковий запис" } }, "security": { @@ -1995,6 +2007,7 @@ "delete_confirm": "Ви впевнені, що хочете видалити цей контакт?", "local_mode": "Контакти зберігаються локально (сервер не підтримує контакти JMAP)", "back_to_contacts": "Назад до контактів", + "back_to_email": "Назад до листа", "tabs": { "all": "все", "groups": "Групи" @@ -2291,7 +2304,8 @@ "has_email": "З ел. поштою", "has_phone": "З телефоном", "has_photo": "З фото" - } + }, + "open_categories": "Відкрити категорії" }, "calendar": { "title": "Календар", @@ -2663,7 +2677,8 @@ "oct": "жовт.", "nov": "лист.", "dec": "груд." - } + }, + "nav_open_menu": "Відкрити меню" }, "advanced_search": { "title": "Розширений пошук", @@ -2818,7 +2833,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 fcbaa79c..87a56b97 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1238,6 +1238,18 @@ "last_sync": { "label": "上次同步", "value": "{time}" + }, + "accounts": { + "title": "已登录账户", + "description": "拖动以重新排序账户在账户下拉菜单中的显示顺序", + "active": "当前活动账户", + "default_badge": "默认账户", + "set_default": "设为默认", + "switch_to": "切换到此账户", + "move_up": "上移", + "move_down": "下移", + "drag_handle": "拖动以重新排序", + "add": "添加账户" } }, "security": { @@ -1995,6 +2007,7 @@ "delete_confirm": "您确定要删除此联系人吗?", "local_mode": "联系人存储在本地(服务器不支持 JMAP 联系人)", "back_to_contacts": "返回联系人", + "back_to_email": "返回邮件", "tabs": { "all": "全部", "groups": "群组" @@ -2291,7 +2304,8 @@ "has_email": "有邮箱", "has_phone": "有电话", "has_photo": "有照片" - } + }, + "open_categories": "打开分类" }, "calendar": { "title": "日历", @@ -2663,7 +2677,8 @@ "oct": "10月", "nov": "11月", "dec": "12月" - } + }, + "nav_open_menu": "打开菜单" }, "advanced_search": { "title": "高级搜索", @@ -2818,7 +2833,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-lock.json b/package-lock.json index 09251903..d5a2f42e 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..7d8505d1 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", @@ -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" }, 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/__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/__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/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; diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 8c09f440..5b7fcd9c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'; import { JMAPClient, RateLimitError } from '@/lib/jmap/client'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { useIdentityStore } from './identity-store'; +import { setClientLookup } from './client-registry'; import { useContactStore } from './contact-store'; import { useVacationStore } from './vacation-store'; import { useCalendarStore } from './calendar-store'; @@ -1255,7 +1256,7 @@ export const useAuthStore = create()( return; } - // Orphan-cookie adoption — when no accounts are registered but a + // Orphan-cookie adoption - when no accounts are registered but a // basic-auth session cookie is present (set by /api/auth/impersonate // or by another server-side hand-off), promote it into the account // registry so the normal restoration path picks it up. Without this @@ -1661,3 +1662,7 @@ export const useAuthStore = create()( } ) ); + +// Expose getClientForAccount to the calendar/contact stores via a small +// 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 f4162593..1fb94a40 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -10,6 +10,36 @@ import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { generateUUID } from '@/lib/utils'; import { apiFetch } from '@/lib/browser-navigation'; import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar'; +import { getClientByLocalAccountId } from './client-registry'; + +/** + * When the Pro shell aggregates calendars/events from every connected + * account, the entity carries a `localAccountId` pointing back to the + * owning JMAP client. Mutations need to use *that* client - the active + * client (passed in by the page) could be on a different server entirely. + * Falls back to the active client when `localAccountId` is unset or no + * matching client is registered. + * + * Lookup goes through `client-registry` (not a direct auth-store import) + * to avoid a top-level cycle: auth-store already imports this module to + * bootstrap feature stores after login. + */ +function resolveAccountClient(active: T, localAccountId?: string): T { + if (!localAccountId) return active; + const lookup = getClientByLocalAccountId(localAccountId) as T | undefined; + return lookup ?? active; +} + +/** + * Strip the local-account namespace prefix from an id (if present). Used + * before passing ids back to a JMAP client, since the prefix only exists + * to keep multi-account ids unique inside the client-side store. + */ +function stripLocalAccountPrefix(id: string, localAccountId?: string): string { + if (!localAccountId) return id; + const prefix = `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`; + return id.startsWith(prefix) ? id.slice(prefix.length) : id; +} // In-flight refresh dedup. Concurrent callers (auto-interval + // manual refresh, two account-switch reloads, etc.) share the same @@ -24,6 +54,59 @@ export function isCalendarViewMode(value: unknown): value is CalendarViewMode { return typeof value === 'string' && CALENDAR_VIEW_MODES.includes(value as CalendarViewMode); } +/** + * Prefix used to namespace calendar/event IDs that belong to a non-active + * JMAP account when the Pro shell aggregates across accounts. The active + * account's IDs are left untouched so existing single-account code paths + * (links, deep-links, JMAP mutations) keep working unchanged. + */ +const CROSS_ACCOUNT_ID_DELIMITER = '::'; + +function buildCrossAccountIdPrefix(localAccountId: string): string { + return `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`; +} + +function prefixCalendarsWithLocalAccount( + calendars: Calendar[], + localAccountId: string, + isActiveAccount: boolean, +): Calendar[] { + if (isActiveAccount) { + return calendars.map((cal) => ({ ...cal, localAccountId })); + } + const prefix = buildCrossAccountIdPrefix(localAccountId); + // Preserve each calendar's original `isShared` flag - it distinguishes + // the user's own calendars on the other account from calendars shared + // *into* that account by yet another user. The sidebar uses this split + // to render "My Calendars" vs "Shared" sub-sections per account. + return calendars.map((cal) => ({ + ...cal, + id: `${prefix}${cal.id}`, + localAccountId, + })); +} + +function prefixEventsWithLocalAccount( + events: CalendarEvent[], + localAccountId: string, + isActiveAccount: boolean, +): CalendarEvent[] { + if (isActiveAccount) { + return events.map((event) => ({ ...event, localAccountId })); + } + const prefix = buildCrossAccountIdPrefix(localAccountId); + return events.map((event) => ({ + ...event, + id: `${prefix}${event.id}`, + localAccountId, + calendarIds: event.calendarIds + ? Object.fromEntries( + Object.entries(event.calendarIds).map(([calId, v]) => [`${prefix}${calId}`, v]), + ) + : event.calendarIds, + })); +} + function mapCalendarIdsToStoreIds( calendarIds: Record | undefined, calendars: Calendar[], @@ -103,7 +186,7 @@ export interface ICalSubscription { url: string; calendarId: string; // The JMAP account this subscription belongs to. Optional for back- - // compat with subs persisted before multi-account scoping landed — + // compat with subs persisted before multi-account scoping landed - // legacy entries with no accountId are shown only in whichever account // the user has active (treated as floating). New subs always set it. accountId?: string; @@ -113,6 +196,17 @@ export interface ICalSubscription { lastRefreshed: string | null; } +/** + * One connected JMAP account. When the Pro shell aggregates calendars from + * every logged-in account, the page hands the calendar store a list of + * these so we can fetch + tag each account's data with its local app-store + * accountId (used to route mutations back to the right client). + */ +export interface CalendarAccountClient { + localAccountId: string; + client: IJMAPClient; +} + interface CalendarStore { calendars: Calendar[]; events: CalendarEvent[]; @@ -129,6 +223,8 @@ interface CalendarStore { setSupported: (supported: boolean) => void; fetchCalendars: (client: IJMAPClient) => Promise; fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise; + fetchAllAccountsCalendars: (accounts: CalendarAccountClient[], activeLocalAccountId: string) => Promise; + fetchAllAccountsEvents: (accounts: CalendarAccountClient[], activeLocalAccountId: string, start: string, end: string) => Promise; createEvent: (client: IJMAPClient, event: Partial, sendSchedulingMessages?: boolean) => Promise; updateEvent: (client: IJMAPClient, id: string, updates: Partial, sendSchedulingMessages?: boolean) => Promise; deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise; @@ -230,25 +326,92 @@ export const useCalendarStore = create()( } }, + fetchAllAccountsCalendars: async (accounts, activeLocalAccountId) => { + set({ isLoading: true, error: null }); + try { + const results = await Promise.all( + accounts.map(async ({ client, localAccountId }) => { + try { + const list = await client.getAllCalendars(); + return prefixCalendarsWithLocalAccount( + list, + localAccountId, + localAccountId === activeLocalAccountId, + ); + } catch (error) { + debug.error(`Failed to fetch calendars for account ${localAccountId}:`, error); + return [] as Calendar[]; + } + }), + ); + const calendars = results.flat(); + const { selectedCalendarIds } = get(); + const validIds = calendars.map(c => c.id); + const stillValid = selectedCalendarIds.filter(id => validIds.includes(id) || id === BIRTHDAY_CALENDAR_ID); + set({ + calendars, + isLoading: false, + selectedCalendarIds: stillValid.length > 0 ? stillValid : validIds, + }); + } catch (error) { + debug.error('Failed to fetch all-account calendars:', error); + set({ error: 'Failed to load calendars', isLoading: false }); + } + }, + + fetchAllAccountsEvents: async (accounts, activeLocalAccountId, start, end) => { + set({ isLoadingEvents: true, error: null }); + try { + const results = await Promise.all( + accounts.map(async ({ client, localAccountId }) => { + try { + const raw = await client.queryAllCalendarEvents({ after: start, before: end }); + const valid = raw.filter(e => typeof e.start === 'string' && e.start); + const expanded = expandRecurringEvents(valid, start, end); + return prefixEventsWithLocalAccount( + expanded, + localAccountId, + localAccountId === activeLocalAccountId, + ); + } catch (error) { + debug.error(`Failed to fetch events for account ${localAccountId}:`, error); + return [] as CalendarEvent[]; + } + }), + ); + set({ events: results.flat(), isLoadingEvents: false, dateRange: { start, end } }); + } catch (error) { + debug.error('Failed to fetch all-account events:', error); + set({ error: 'Failed to load events', isLoadingEvents: false }); + } + }, + createEvent: async (client, event, sendSchedulingMessages) => { set({ error: null }); try { - // Resolve shared calendar context from calendarIds + // Resolve shared calendar context from calendarIds. Also pin the + // local account from the calendar so we route through that + // server's client when in multi-account Pro mode. let targetAccountId = event.accountId; + let localAccountId = event.localAccountId; const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event }); if (event.calendarIds) { const remapped: Record = {}; for (const calId of Object.keys(event.calendarIds)) { const cal = get().calendars.find(c => c.id === calId); + if (cal?.localAccountId) localAccountId = cal.localAccountId; if (cal?.isShared && cal.originalId) { targetAccountId = cal.accountId; remapped[cal.originalId] = true; + } else if (cal?.originalId) { + remapped[cal.originalId] = true; } else { remapped[calId] = true; } } cleanEvent.calendarIds = remapped; } + client = resolveAccountClient(client, localAccountId); if (event.originalCalendarIds) { cleanEvent.calendarIds = event.originalCalendarIds; } @@ -321,8 +484,9 @@ export const useCalendarStore = create()( try { // Resolve shared event IDs and client-side expanded occurrence IDs const storeEvent = get().events.find(e => e.id === id); - const realId = storeEvent?.originalId || id; + const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId); const targetAccountId = storeEvent?.accountId; + client = resolveAccountClient(client, storeEvent?.localAccountId); debug.log('calendar', 'Calendar updateEvent', { storeId: id, realId, @@ -404,8 +568,9 @@ export const useCalendarStore = create()( try { // Resolve shared event IDs and client-side expanded occurrence IDs const storeEvent = get().events.find(e => e.id === eventId); - const realId = storeEvent?.originalId || eventId; + const realId = storeEvent?.originalId || stripLocalAccountPrefix(eventId, storeEvent?.localAccountId); const targetAccountId = storeEvent?.accountId; + client = resolveAccountClient(client, storeEvent?.localAccountId); // Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1 const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1'); const patchKey = `participants/${escapedId}/participationStatus`; @@ -443,8 +608,9 @@ export const useCalendarStore = create()( importEvents: async (client, events, calendarId) => { // Resolve shared calendar IDs const cal = get().calendars.find(c => c.id === calendarId); - const realCalendarId = cal?.originalId || calendarId; + const realCalendarId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); const targetAccountId = cal?.accountId; + client = resolveAccountClient(client, cal?.localAccountId); // Deduplicate UIDs: Stalwart enforces UID uniqueness across all calendars. // - Events already in the target calendar → skip (true duplicates) @@ -611,8 +777,9 @@ export const useCalendarStore = create()( try { // Resolve shared event IDs and client-side expanded occurrence IDs const storeEvent = get().events.find(e => e.id === id); - const realId = storeEvent?.originalId || id; + const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId); const targetAccountId = storeEvent?.accountId; + client = resolveAccountClient(client, storeEvent?.localAccountId); if (sendSchedulingMessages) { try { const event = await client.getCalendarEvent(realId, targetAccountId); @@ -649,8 +816,9 @@ export const useCalendarStore = create()( set({ error: null }); try { const cal = get().calendars.find(c => c.id === calendarId); - const realId = cal?.originalId || calendarId; + const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); const targetAccountId = cal?.accountId; + client = resolveAccountClient(client, cal?.localAccountId); await client.updateCalendar(realId, updates, targetAccountId); set((state) => ({ calendars: state.calendars.map(c => @@ -668,8 +836,9 @@ export const useCalendarStore = create()( set({ error: null }); try { const cal = get().calendars.find(c => c.id === calendarId); - const realId = cal?.originalId || calendarId; + const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); const targetAccountId = cal?.accountId; + client = resolveAccountClient(client, cal?.localAccountId); await client.setCalendarShare(realId, principalId, rights, targetAccountId); set((state) => ({ calendars: state.calendars.map(c => { @@ -707,8 +876,9 @@ export const useCalendarStore = create()( set({ error: null }); try { const cal = get().calendars.find(c => c.id === calendarId); - const realId = cal?.originalId || calendarId; + const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); const targetAccountId = cal?.accountId; + client = resolveAccountClient(client, cal?.localAccountId); await client.deleteCalendar(realId, targetAccountId); set((state) => ({ calendars: state.calendars.filter(c => c.id !== calendarId), @@ -726,8 +896,9 @@ export const useCalendarStore = create()( set({ error: null }); try { const cal = get().calendars.find(c => c.id === calendarId); - const realCalId = cal?.originalId || calendarId; + const realCalId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); const targetAccountId = cal?.accountId; + client = resolveAccountClient(client, cal?.localAccountId); let totalRemoved = 0; // Loop to handle pagination (getCalendarEvents has a 1000 limit) let hasMore = true; @@ -739,7 +910,7 @@ export const useCalendarStore = create()( 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 }> = []; @@ -837,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); @@ -926,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 }); @@ -1057,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 new file mode 100644 index 00000000..3a3baa36 --- /dev/null +++ b/stores/client-registry.ts @@ -0,0 +1,23 @@ +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +/** + * Tiny indirection used by the calendar and contact stores to look up a + * JMAP client by local account ID without importing `auth-store` directly + * - that would form a top-level cycle (auth-store already imports the + * feature stores to bootstrap them after login). + * + * `auth-store` registers its `getClientForAccount` on module init via + * `setClientLookup`; the feature stores call `getClientByLocalAccountId` + * inside their mutations. + */ +type ClientLookup = (localAccountId: string) => IJMAPClient | undefined; + +let lookup: ClientLookup | null = null; + +export function setClientLookup(fn: ClientLookup): void { + lookup = fn; +} + +export function getClientByLocalAccountId(localAccountId: string): IJMAPClient | undefined { + return lookup ? lookup(localAccountId) : undefined; +} diff --git a/stores/contact-store.ts b/stores/contact-store.ts index a376ed7a..8f908f22 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -4,6 +4,82 @@ import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { generateUUID } from '@/lib/utils'; import { debug } from '@/lib/debug'; +import { getClientByLocalAccountId } from './client-registry'; + +/** One connected JMAP account for contact multi-account aggregation. */ +export interface ContactAccountClient { + localAccountId: string; + client: IJMAPClient; +} + +/** + * Prefix used to namespace contact/address-book IDs that belong to a + * non-active JMAP account when the Pro shell aggregates across accounts. + * The active account's IDs are left untouched so existing single-account + * code paths keep working unchanged. + */ +const CROSS_ACCOUNT_ID_DELIMITER = '::'; + +function buildCrossAccountIdPrefix(localAccountId: string): string { + return `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`; +} + +function prefixAddressBooksWithLocalAccount( + books: AddressBook[], + localAccountId: string, + isActiveAccount: boolean, +): AddressBook[] { + if (isActiveAccount) { + return books.map((b) => ({ ...b, localAccountId })); + } + const prefix = buildCrossAccountIdPrefix(localAccountId); + return books.map((b) => ({ + ...b, + id: `${prefix}${b.id}`, + localAccountId, + })); +} + +function prefixContactsWithLocalAccount( + contacts: ContactCard[], + localAccountId: string, + isActiveAccount: boolean, +): ContactCard[] { + if (isActiveAccount) { + return contacts.map((c) => ({ ...c, localAccountId })); + } + const prefix = buildCrossAccountIdPrefix(localAccountId); + return contacts.map((c) => ({ + ...c, + id: `${prefix}${c.id}`, + localAccountId, + addressBookIds: c.addressBookIds + ? Object.fromEntries( + Object.entries(c.addressBookIds).map(([bookId, v]) => [`${prefix}${bookId}`, v]), + ) + : c.addressBookIds, + })); +} + +/** + * Route mutations back through the client that owns the target entity + * when in multi-account Pro mode. See [[useProMultiAccountContacts]]. + * + * Lookup goes through `client-registry` (not a direct auth-store import) + * to avoid a top-level cycle: auth-store already imports this module to + * bootstrap feature stores after login. + */ +function resolveAccountClient(active: T, localAccountId?: string): T { + if (!localAccountId) return active; + const lookup = getClientByLocalAccountId(localAccountId) as T | undefined; + return lookup ?? active; +} + +function stripLocalAccountPrefix(id: string, localAccountId?: string): string { + if (!localAccountId) return id; + const prefix = `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`; + return id.startsWith(prefix) ? id.slice(prefix.length) : id; +} export function getContactDisplayName(contact: ContactCard): string { if (contact.name) { @@ -37,10 +113,27 @@ export function getContactPrimaryEmail(contact: ContactCard): string { return Object.values(contact.emails)[0]?.address || ''; } +// Some JMAP servers (notably Stalwart, see issue #307) emit photo data URIs +// without a mediatype, like `data:base64,...` or `data:;base64,...`. Per +// RFC 2397 the missing/empty mediatype defaults to `text/plain`, so browsers +// won't render the bytes as an image. Rewrite to include a mediatype. +export function normalizeContactPhotoUri(uri: string, mediaType?: string): string { + const mime = mediaType && mediaType.includes('/') ? mediaType : 'image/jpeg'; + if (uri.startsWith('data:base64,')) { + return `data:${mime};base64,${uri.slice('data:base64,'.length)}`; + } + if (uri.startsWith('data:;base64,')) { + return `data:${mime};base64,${uri.slice('data:;base64,'.length)}`; + } + return uri; +} + export function getContactPhotoUri(contact: ContactCard): string | undefined { if (!contact.media) return undefined; for (const media of Object.values(contact.media)) { - if (media.kind === 'photo' && media.uri) return media.uri; + if (media.kind === 'photo' && media.uri) { + return normalizeContactPhotoUri(media.uri, media.mediaType); + } } return undefined; } @@ -68,6 +161,8 @@ interface ContactStore { fetchContacts: (client: IJMAPClient) => Promise; fetchAddressBooks: (client: IJMAPClient) => Promise; + fetchAllAccountsContacts: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise; + fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise; createContact: (client: IJMAPClient, contact: Partial) => Promise; updateContact: (client: IJMAPClient, id: string, updates: Partial) => Promise; deleteContact: (client: IJMAPClient, id: string) => Promise; @@ -185,12 +280,64 @@ export const useContactStore = create()( } }, + fetchAllAccountsContacts: async (accounts, activeLocalAccountId) => { + set({ isLoading: true, error: null }); + try { + const results = await Promise.all( + accounts.map(async ({ client, localAccountId }) => { + try { + const list = await client.getAllContacts(); + return prefixContactsWithLocalAccount( + list, + localAccountId, + localAccountId === activeLocalAccountId, + ); + } catch (error) { + debug.error(`Failed to fetch contacts for account ${localAccountId}:`, error); + return [] as ContactCard[]; + } + }), + ); + set({ contacts: results.flat(), isLoading: false }); + } catch (error) { + console.error('Failed to fetch all-account contacts:', error); + set({ error: 'Failed to fetch contacts', isLoading: false }); + } + }, + + fetchAllAccountsAddressBooks: async (accounts, activeLocalAccountId) => { + try { + const results = await Promise.all( + accounts.map(async ({ client, localAccountId }) => { + try { + const list = await client.getAllAddressBooks(); + return prefixAddressBooksWithLocalAccount( + list, + localAccountId, + localAccountId === activeLocalAccountId, + ); + } catch (error) { + debug.error(`Failed to fetch address books for account ${localAccountId}:`, error); + return [] as AddressBook[]; + } + }), + ); + set({ addressBooks: results.flat() }); + } catch (error) { + console.error('Failed to fetch all-account address books:', error); + set({ error: 'Failed to fetch address books' }); + } + }, + createContact: async (client, contact) => { set({ isLoading: true, error: null }); try { - // Determine target account from the selected address book + // Determine target account from the selected address book. Also + // pin the local account so we route through the right server's + // client in multi-account Pro mode. let accountId = contact.isShared ? contact.accountId : undefined; let cleanedContact = contact; + let localAccountId = contact.localAccountId; // De-namespace addressBookIds if they reference a shared address book if (contact.addressBookIds) { @@ -199,9 +346,12 @@ export const useContactStore = create()( let sharedAccountId: string | undefined; for (const [bookId, value] of Object.entries(contact.addressBookIds)) { const book = books.find(b => b.id === bookId); + if (book?.localAccountId) localAccountId = book.localAccountId; if (book?.isShared && book.originalId) { deNamespaced[book.originalId] = value; sharedAccountId = book.accountId; + } else if (book?.originalId) { + deNamespaced[book.originalId] = value; } else { deNamespaced[bookId] = value; } @@ -214,6 +364,7 @@ export const useContactStore = create()( } } + client = resolveAccountClient(client, localAccountId); const created = await client.createContact(cleanedContact, accountId); // Preserve shared account metadata if (contact.isShared && contact.accountId) { @@ -238,8 +389,9 @@ export const useContactStore = create()( set({ error: null }); try { const contact = get().contacts.find(c => c.id === id); - const originalId = contact?.originalId || id; + const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId); const accountId = contact?.isShared ? contact.accountId : undefined; + client = resolveAccountClient(client, contact?.localAccountId); // De-namespace addressBookIds for shared contacts before sending to JMAP server let cleanedUpdates = updates; @@ -271,8 +423,9 @@ export const useContactStore = create()( set({ error: null }); try { const contact = get().contacts.find(c => c.id === id); - const originalId = contact?.originalId || id; + const originalId = contact?.originalId || stripLocalAccountPrefix(id, contact?.localAccountId); const accountId = contact?.isShared ? contact.accountId : undefined; + client = resolveAccountClient(client, contact?.localAccountId); await client.deleteContact(originalId, accountId); set((state) => { const removedIds = new Set([id]); @@ -642,8 +795,9 @@ export const useContactStore = create()( const trimmed = newName.trim(); if (!trimmed) return; try { - const originalId = addressBook.originalId || addressBook.id; + const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId); const accountId = addressBook.isShared ? addressBook.accountId : undefined; + client = resolveAccountClient(client, addressBook.localAccountId); await client.updateAddressBook(originalId, { name: trimmed }, accountId); set((state) => ({ addressBooks: state.addressBooks.map(b => @@ -660,8 +814,9 @@ export const useContactStore = create()( removeAddressBook: async (client, addressBook) => { set({ error: null }); try { - const originalId = addressBook.originalId || addressBook.id; + const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId); const accountId = addressBook.isShared ? addressBook.accountId : undefined; + client = resolveAccountClient(client, addressBook.localAccountId); await client.deleteAddressBook(originalId, accountId); set((state) => ({ addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id), @@ -677,8 +832,9 @@ export const useContactStore = create()( shareAddressBook: async (client, addressBook, principalId, rights) => { set({ error: null }); try { - const originalId = addressBook.originalId || addressBook.id; + const originalId = addressBook.originalId || stripLocalAccountPrefix(addressBook.id, addressBook.localAccountId); const accountId = addressBook.isShared ? addressBook.accountId : undefined; + client = resolveAccountClient(client, addressBook.localAccountId); await client.setAddressBookShare(originalId, principalId, rights, accountId); set((state) => ({ addressBooks: state.addressBooks.map(b => { diff --git a/stores/email-store.ts b/stores/email-store.ts index d9f9e37f..89bdb27e 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -7,7 +7,7 @@ import { useCalendarStore } from "@/stores/calendar-store"; import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import type { ExternalSearchResult } from "@/lib/plugin-types"; -import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; +import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; @@ -25,6 +25,21 @@ type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; interface EmailStore { emails: Email[]; mailboxes: Mailbox[]; + /** + * Mailbox caches keyed by accountId. Populated for every connected account + * when the Pro shell is active so the sidebar can render per-account groups + * Thunderbird-style. The active account's mailboxes still live in + * `mailboxes` for back-compat with the single-account view. + */ + accountMailboxes: Record; + /** + * When set, the mail view is reading from this account instead of the + * global active one. `null` means "use the global active account" - i.e. + * the standard single-account behavior. Selecting a folder under a + * non-active account in the Pro sidebar updates this without changing + * `useAuthStore.activeAccountId`. + */ + viewingAccountId: string | null; selectedEmail: Email | null; selectedMailbox: string; isLoading: boolean; @@ -75,6 +90,23 @@ interface EmailStore { setEmails: (emails: Email[]) => void; setMailboxes: (mailboxes: Mailbox[]) => void; + /** Cache or update the mailbox list for a specific account. */ + setAccountMailboxes: (accountId: string, mailboxes: Mailbox[]) => void; + /** Wipe the per-account mailbox cache (e.g. on logout). */ + clearAccountMailboxes: () => void; + setViewingAccount: (accountId: string | null) => void; + /** + * Atomic version of (setViewingAccount + selectMailbox). Pass `null` for + * the active account; pass an accountId to view a non-active account's + * folder without changing the global active account. + */ + selectAccountMailbox: (accountId: string | null, mailboxId: string) => void; + /** + * Fetch mailboxes via the supplied client and store them under + * `accountMailboxes[accountId]`. Used by the Pro shell to populate the + * sidebar's per-account groups for every connected account. + */ + fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise; selectEmail: (email: Email | null) => void; selectMailbox: (mailboxId: string) => void; setLoading: (loading: boolean) => void; @@ -107,6 +139,20 @@ interface EmailStore { moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise; moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; + /** + * Move emails across JMAP accounts. JMAP has no native cross-account move, + * so for each email we fetch the source's raw RFC822 blob, import it into + * the destination account's target mailbox, then delete the original. + * `emailIdsBySource` maps each source accountId to the emails it owns; + * pass the active account's id explicitly (no `__default__` sentinel). + * `destMailboxId` is the raw JMAP id on the destination server (not the + * `accountId:mailboxId` namespace used for shared folders). + */ + crossAccountMoveEmails: ( + emailIdsBySource: Map, + destAccountId: string, + destMailboxId: string, + ) => Promise; searchEmails: (client: IJMAPClient, query: string) => Promise; advancedSearch: (client: IJMAPClient) => Promise; setSearchFilters: (filters: Partial) => void; @@ -231,6 +277,78 @@ function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEm return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending'; } +/** + * When the mail view is showing a non-active account (Pro shell's + * Thunderbird-style sidebar), redirect read/write operations to that + * account's JMAP client and mailbox cache. Returns the passed-in values + * unchanged for the standard single-account flow. + * + * Compose/send still routes through the caller's client (the active + * account), since identity binding for cross-account sending is a separate + * concern. + */ +function resolveActionClient(passedClient: IJMAPClient): IJMAPClient { + const viewingId = useEmailStore.getState().viewingAccountId; + if (!viewingId) return passedClient; + const c = useAuthStore.getState().getClientForAccount(viewingId); + return c ?? passedClient; +} + +function resolveActionMailboxes(): Mailbox[] { + const state = useEmailStore.getState(); + if (state.viewingAccountId) { + return state.accountMailboxes[state.viewingAccountId] ?? state.mailboxes; + } + return state.mailboxes; +} + +/** + * Builds the `UnifiedAccountClient[]` list used by every unified fan-out + * action (browse, load-more, search). Each entry has a JMAP client plus a + * fresh mailbox list so the helpers can resolve the role mailbox per account. + * Accounts whose mailbox fetch fails are skipped - the unified result will + * surface that in its per-account error map. + */ +async function buildUnifiedAccountClients(): Promise { + const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected); + const allClients = useAuthStore.getState().getAllConnectedClients(); + const built: UnifiedAccountClient[] = []; + for (const a of authAccounts) { + const c = allClients.get(a.id); + if (!c) continue; + try { + const mailboxes = await c.getMailboxes(); + built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes }); + } catch { + /* skip account on mailbox fetch failure */ + } + } + return built; +} + +/** + * After a mailbox-list mutation (create/rename/delete/etc.), refresh the + * cache for whichever account we're operating on. Writes the result to the + * standard `mailboxes` slot for the active account, or the per-account + * cache for non-active accounts so the Pro sidebar stays in sync. + */ +async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise { + const viewingId = useEmailStore.getState().viewingAccountId; + const client = resolveActionClient(fallbackClient); + try { + const mailboxes = await client.getMailboxes(); + if (viewingId) { + useEmailStore.setState((state) => ({ + accountMailboxes: { ...state.accountMailboxes, [viewingId]: mailboxes }, + })); + } else { + useEmailStore.setState({ mailboxes }); + } + } catch (error) { + console.error('Failed to refresh mailboxes after mutation:', error); + } +} + // Find the trash mailbox for a given account scope. Prefers JMAP role, but // falls back to name matching ("trash" / "deleted") so users with custom or // pre-existing folders (e.g. "Deleted Items") aren't silently destroyed. @@ -256,6 +374,8 @@ function findTrashMailbox( export const useEmailStore = create((set, get) => ({ emails: [], mailboxes: [], + accountMailboxes: {}, + viewingAccountId: null, selectedEmail: null, selectedMailbox: "", isLoading: false, @@ -309,6 +429,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 }); @@ -334,7 +481,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); @@ -464,9 +611,10 @@ export const useEmailStore = create((set, get) => ({ await get().fetchScheduledEmails(client); return; } + const effectiveClient = resolveActionClient(client); // Find the mailbox to get its accountId (for shared folder support) - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === targetMailboxId); // Only pass accountId for shared mailboxes, not for primary account const accountId = mailbox?.isShared ? mailbox.accountId : undefined; @@ -482,7 +630,7 @@ export const useEmailStore = create((set, get) => ({ // When filtering by tag, omit the mailbox constraint so emails across // all folders that carry the tag are returned. - const result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); + const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), hasMoreEmails: result.hasMore, @@ -507,27 +655,28 @@ export const useEmailStore = create((set, get) => ({ // Don't load if already loading or no more emails if (isLoadingMore || !hasMoreEmails) return; - // Unified view uses a different fan-out loader. Rebuild the per-account - // client list from auth/account stores and delegate. + // Unified view uses a different fan-out loader. When a search query or + // advanced filter is active we paginate the unified search instead of the + // unified browse, so "load more" matches what's on screen. if (isUnifiedView && unifiedRole) { set({ isLoadingMore: true, error: null }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const position = emails.length; - const authAccounts = useAccountStore.getState().accounts.filter(a => a.isConnected); - const allClients = useAuthStore.getState().getAllConnectedClients(); - const built: UnifiedAccountClient[] = []; - for (const a of authAccounts) { - const c = allClients.get(a.id); - if (!c) continue; - try { - const mailboxes = await c.getMailboxes(); - built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes }); - } catch { - /* skip account on mailbox fetch failure */ - } - } - const result = await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position); + const built = await buildUnifiedAccountClients(); + const { searchFilters } = get(); + const hasFilters = !isFilterEmpty(searchFilters); + const result = hasFilters + ? await advancedSearchUnifiedEmails( + built, + unifiedRole, + (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), + emailsPerPage, + position, + ) + : searchQuery + ? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, position) + : await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position); const currentEmails = get().emails; const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id)); @@ -556,6 +705,7 @@ export const useEmailStore = create((set, get) => ({ return; } + const effectiveClient = resolveActionClient(client); // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; @@ -568,21 +718,21 @@ export const useEmailStore = create((set, get) => ({ const hasFilters = !isFilterEmpty(searchFilters); if (searchQuery || hasFilters) { - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const jmapMailboxId = mailbox?.originalId || selectedMailbox; const accountId = mailbox?.isShared ? mailbox.accountId : undefined; if (hasFilters) { const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position); + result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position); } else { - result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); + result = await effectiveClient.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); } } else { // Load more from mailbox // Find the mailbox to get its accountId (for shared folder support) - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); // Only pass accountId for shared mailboxes, not for primary account const accountId = mailbox?.isShared ? mailbox.accountId : undefined; @@ -590,7 +740,7 @@ export const useEmailStore = create((set, get) => ({ const jmapMailboxId = mailbox?.originalId || selectedMailbox; // When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails). - result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined); + result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined); } // Use fresh state when merging to avoid overwriting concurrent updates @@ -621,13 +771,13 @@ export const useEmailStore = create((set, get) => ({ try { // Find the selected mailbox to determine accountId (for shared folders) const selectedMailboxId = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId); // Only pass accountId for shared mailboxes const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - const email = await client.getEmail(emailId, accountId); + const email = await resolveActionClient(client).getEmail(emailId, accountId); if (email) { const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId); @@ -645,7 +795,7 @@ export const useEmailStore = create((set, get) => ({ fetchQuota: async (client) => { try { - const quota = await client.getQuota(); + const quota = await resolveActionClient(client).getQuota(); set({ quota }); } catch { // Don't set error state as quota is optional @@ -704,6 +854,7 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isUnread = !email.keywords?.$seen; + const effectiveClient = resolveActionClient(client); // Get delete action preference from settings const deleteAction = useSettingsStore.getState().deleteAction; @@ -711,7 +862,7 @@ export const useEmailStore = create((set, get) => ({ // Determine accountId for shared folders const selectedMailboxId = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; @@ -728,7 +879,7 @@ export const useEmailStore = create((set, get) => ({ if (trashMailbox) { // Use originalId for shared mailboxes if available const trashId = trashMailbox.originalId || trashMailbox.id; - await client.moveToTrash(emailId, trashId, accountId); + await effectiveClient.moveToTrash(emailId, trashId, accountId); // Remove from local state (email moved to trash, not in current view) set((state) => { @@ -775,7 +926,7 @@ export const useEmailStore = create((set, get) => ({ } // Permanent delete - await client.deleteEmail(emailId); + await effectiveClient.deleteEmail(emailId); // Remove from local state and update mailbox counters if needed set((state) => { @@ -849,11 +1000,11 @@ export const useEmailStore = create((set, get) => ({ // Determine accountId for shared folders const selectedMailboxId = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - await client.markAsRead(emailId, read, accountId); + await resolveActionClient(client).markAsRead(emailId, read, accountId); // Update local state including mailbox counters set((state) => { @@ -917,14 +1068,15 @@ export const useEmailStore = create((set, get) => ({ const isUnread = !email.keywords?.$seen; const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : []; - const { selectedMailbox, mailboxes } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; - await client.moveEmail(emailId, jmapDestId, accountId); + await resolveActionClient(client).moveEmail(emailId, jmapDestId, accountId); set((state) => { const updatedMailboxes = state.mailboxes.map(mailbox => { @@ -971,7 +1123,8 @@ export const useEmailStore = create((set, get) => ({ } try { - const { emails, mailboxes, selectedMailbox, isUnifiedView } = get(); + const { emails, selectedMailbox, isUnifiedView } = get(); + const mailboxes = resolveActionMailboxes(); const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; const idSet = new Set(emailIds); @@ -993,7 +1146,7 @@ export const useEmailStore = create((set, get) => ({ } else { const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; - await client.batchMoveEmails(emailIds, jmapDestId, accountId); + await resolveActionClient(client).batchMoveEmails(emailIds, jmapDestId, accountId); } // Adjust counters and drop moved emails from the current view. @@ -1037,6 +1190,110 @@ export const useEmailStore = create((set, get) => ({ } }, + crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => { + if (emailIdsBySource.size === 0) return; + set({ isLoading: true, error: null }); + try { + const destClient = useAuthStore.getState().getClientForAccount(destAccountId); + if (!destClient) { + throw new Error('Destination account is not connected'); + } + + const movedIds: string[] = []; + const failures: Array<{ emailId: string; error: string }> = []; + + for (const [sourceAccountId, emailIds] of emailIdsBySource.entries()) { + const sourceClient = useAuthStore.getState().getClientForAccount(sourceAccountId); + if (!sourceClient) { + for (const emailId of emailIds) { + failures.push({ emailId, error: 'Source account not connected' }); + } + continue; + } + + // Fan the per-email copy/import/delete pipeline out in parallel. + // JMAP has no atomic cross-account move, so we accept that a crash + // mid-flight could leave a duplicate; the delete on success keeps + // the source clean in the happy path. + const results = await Promise.allSettled( + emailIds.map(async (emailId) => { + const full = await sourceClient.getEmail(emailId); + if (!full?.blobId) { + throw new Error('Source email has no raw blob to copy'); + } + const blob = await sourceClient.fetchBlob(full.blobId); + const keywords: Record = { ...(full.keywords ?? {}) }; + await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords); + await sourceClient.deleteEmail(emailId); + return emailId; + }), + ); + + results.forEach((outcome, i) => { + const emailId = emailIds[i]; + if (outcome.status === 'fulfilled') { + movedIds.push(emailId); + } else { + const err = outcome.reason; + failures.push({ + emailId, + error: err instanceof Error ? err.message : String(err), + }); + } + }); + } + + // Drop the moved emails from the current view and clear stale selection + // entries. Counter accuracy comes from the mailbox refresh below. + const movedSet = new Set(movedIds); + set((state) => ({ + emails: state.emails.filter((e) => !movedSet.has(e.id)), + selectedEmail: + state.selectedEmail && movedSet.has(state.selectedEmail.id) + ? null + : state.selectedEmail, + selectedEmailIds: (() => { + const next = new Set(state.selectedEmailIds); + for (const id of movedIds) next.delete(id); + return next; + })(), + isLoading: false, + })); + + // Refresh mailbox folder lists/counters for every account we touched. + // Background-only so the move feels instant - counters will catch up. + const activeAccountId = useAuthStore.getState().activeAccountId; + const touched = new Set([destAccountId, ...emailIdsBySource.keys()]); + for (const acctId of touched) { + const c = useAuthStore.getState().getClientForAccount(acctId); + if (!c) continue; + if (acctId === activeAccountId) { + void get().fetchMailboxes(c); + } else { + void get().fetchAccountMailboxes(c, acctId); + } + } + + if (failures.length > 0) { + const first = failures[0]; + throw new Error( + failures.length === 1 + ? `Failed to move email: ${first.error}` + : `Failed to move ${failures.length} email(s); first error: ${first.error}`, + ); + } + } catch (error) { + set({ + isLoading: false, + error: + error instanceof Error + ? error.message + : 'Failed to move emails between accounts', + }); + throw error; + } + }, + moveThreadToMailbox: async (client, emailId, destinationMailboxId) => { try { const state = get(); @@ -1048,12 +1305,14 @@ export const useEmailStore = create((set, get) => ({ return; } - const currentMailbox = state.mailboxes.find(mb => mb.id === state.selectedMailbox); + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); + const currentMailbox = mailboxes.find(mb => mb.id === state.selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; - const destMailbox = state.mailboxes.find(mb => mb.id === destinationMailboxId); + const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; - const thread = await client.getThread(email.threadId, accountId); + const thread = await effectiveClient.getThread(email.threadId, accountId); const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId]; if (threadEmailIds.length <= 1) { @@ -1061,7 +1320,7 @@ export const useEmailStore = create((set, get) => ({ return; } - await client.batchMoveEmails(threadEmailIds, jmapDestId, accountId); + await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId); const removedEmailIds = new Set(threadEmailIds); set((currentState) => { @@ -1093,18 +1352,34 @@ export const useEmailStore = create((set, get) => ({ searchEmails: async (client, query) => { set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state try { + const { isUnifiedView, unifiedRole } = get(); + const emailsPerPage = useSettingsStore.getState().emailsPerPage; + + if (isUnifiedView && unifiedRole) { + const built = await buildUnifiedAccountClients(); + const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0); + const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); + set({ + emails: result.emails, + externalSearchResults: externals, + hasMoreEmails: result.hasMore, + totalEmails: result.total, + isLoading: false, + unifiedErrors: result.errors, + }); + return; + } + // Get the current mailbox to scope the search const selectedMailbox = get().selectedMailbox; - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); // Use originalId for shared mailboxes const jmapMailboxId = mailbox?.originalId || selectedMailbox; // Only pass accountId for shared mailboxes, not for primary account const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - // Get emails per page from settings - const emailsPerPage = useSettingsStore.getState().emailsPerPage; - const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); + const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), @@ -1126,7 +1401,8 @@ export const useEmailStore = create((set, get) => ({ }, advancedSearch: async (client) => { - const { searchQuery, searchFilters, selectedMailbox, mailboxes, searchAbortController } = get(); + const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole } = get(); + const mailboxes = resolveActionMailboxes(); if (searchAbortController) { searchAbortController.abort(); @@ -1143,13 +1419,37 @@ export const useEmailStore = create((set, get) => ({ }); try { + const emailsPerPage = useSettingsStore.getState().emailsPerPage; + + if (isUnifiedView && unifiedRole) { + const built = await buildUnifiedAccountClients(); + const result = await advancedSearchUnifiedEmails( + built, + unifiedRole, + (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), + emailsPerPage, + 0, + ); + if (controller.signal.aborted) return; + const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters }); + set({ + emails: result.emails, + externalSearchResults: externals, + hasMoreEmails: result.hasMore, + totalEmails: result.total, + isLoading: false, + searchAbortController: null, + unifiedErrors: result.errors, + }); + return; + } + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const jmapMailboxId = mailbox?.originalId || selectedMailbox; const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - const emailsPerPage = useSettingsStore.getState().emailsPerPage; - const result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0); + const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); if (controller.signal.aborted) return; @@ -1197,7 +1497,7 @@ export const useEmailStore = create((set, get) => ({ if (!email) return; const isFlagged = email.keywords.$flagged || false; - await client.toggleStar(emailId, !isFlagged); + await resolveActionClient(client).toggleStar(emailId, !isFlagged); // Update local state set((state) => ({ @@ -1229,7 +1529,8 @@ export const useEmailStore = create((set, get) => ({ // Batch operations batchMarkAsRead: async (client, read) => { - const { selectedEmailIds, emails, mailboxes } = get(); + const { selectedEmailIds, emails } = get(); + const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); @@ -1253,7 +1554,7 @@ export const useEmailStore = create((set, get) => ({ }); await Promise.allSettled(promises); } else { - await client.batchMarkAsRead(emailIdsArray, read); + await resolveActionClient(client).batchMarkAsRead(emailIdsArray, read); } // Update local state @@ -1298,7 +1599,8 @@ export const useEmailStore = create((set, get) => ({ }, batchDelete: async (client, permanent = false) => { - const { selectedEmailIds, emails, mailboxes, selectedMailbox } = get(); + const { selectedEmailIds, emails, selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); @@ -1461,7 +1763,7 @@ export const useEmailStore = create((set, get) => ({ }); await Promise.allSettled(promises); } else { - await client.batchMoveEmails(emailIdsArray, toMailboxId); + await resolveActionClient(client).batchMoveEmails(emailIdsArray, toMailboxId); } // Update local state - remove from current view since they moved @@ -1486,7 +1788,8 @@ export const useEmailStore = create((set, get) => ({ }, batchArchive: async (client) => { - const { selectedEmailIds, emails, mailboxes, fetchMailboxes } = get(); + const { selectedEmailIds, emails } = get(); + const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive'); @@ -1500,7 +1803,7 @@ export const useEmailStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { - await client.batchArchiveEmails( + await resolveActionClient(client).batchArchiveEmails( selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })), archiveId, mode, @@ -1511,8 +1814,9 @@ export const useEmailStore = create((set, get) => ({ const remaining = emails.filter(e => !selectedEmailIds.has(e.id)); set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false }); - await fetchMailboxes(client); - // Refresh the current mailbox view (honors active search/filters) + // Refresh the active or viewed account's mailbox cache after the + // archive (a year/month archive can create new sub-folders). + await refreshMailboxesForViewingAccount(client); await get().refreshCurrentMailbox(client); } catch (error) { set({ @@ -1525,7 +1829,8 @@ export const useEmailStore = create((set, get) => ({ // Spam operations markAsSpam: async (client, emailId) => { - const { selectedMailbox, mailboxes, emails } = get(); + const { selectedMailbox, emails } = get(); + const mailboxes = resolveActionMailboxes(); const email = emails.find(e => e.id === emailId); if (!email) return; @@ -1539,7 +1844,7 @@ export const useEmailStore = create((set, get) => ({ }); try { - await client.markAsSpam(emailId, currentMailbox.accountId); + await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId); set(state => ({ emails: state.emails.filter(e => e.id !== emailId), @@ -1552,7 +1857,8 @@ export const useEmailStore = create((set, get) => ({ }, undoSpam: async (client, emailId) => { - const { mailboxes, selectedMailbox } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); // Try cache first (preserves exact original mailbox for toast undo) const cachedData = get().spamUndoCache.get(emailId); @@ -1584,7 +1890,7 @@ export const useEmailStore = create((set, get) => ({ } try { - await client.undoSpam(emailId, targetMailboxId, accountId); + await resolveActionClient(client).undoSpam(emailId, targetMailboxId, accountId); await get().fetchEmails(client, selectedMailbox); } catch (error) { console.error('Failed to restore email:', error); @@ -1593,14 +1899,16 @@ export const useEmailStore = create((set, get) => ({ }, batchMarkAsSpam: async (client, emailIds) => { - const { selectedMailbox, mailboxes } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); if (!currentMailbox) return; try { for (const emailId of emailIds) { - await client.markAsSpam(emailId, currentMailbox.accountId); + await effectiveClient.markAsSpam(emailId, currentMailbox.accountId); } set(state => ({ @@ -1615,7 +1923,9 @@ export const useEmailStore = create((set, get) => ({ }, batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => { - const { mailboxes, selectedMailbox } = get(); + const { selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); // Find inbox (batch operations don't preserve original mailboxes) const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); @@ -1632,7 +1942,7 @@ export const useEmailStore = create((set, get) => ({ try { for (const emailId of emailIds) { - await client.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId); + await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId); } set(state => ({ @@ -1731,7 +2041,8 @@ export const useEmailStore = create((set, get) => ({ try { // Fetch emails for the current mailbox without clearing the list first // This provides a smoother update experience - const mailboxes = get().mailboxes; + const mailboxes = resolveActionMailboxes(); + const effectiveClient = resolveActionClient(client); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const jmapMailboxId = mailbox?.originalId || selectedMailbox; @@ -1747,9 +2058,9 @@ export const useEmailStore = create((set, get) => ({ let result; if (hasFilters || searchQuery) { const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0); + result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0); } else { - result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0); + result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0); } const currentEmails = get().emails; @@ -1844,7 +2155,8 @@ export const useEmailStore = create((set, get) => ({ }, fetchThreadEmails: async (client, threadId) => { - const { threadEmailsCache, selectedMailbox, mailboxes } = get(); + const { threadEmailsCache, selectedMailbox } = get(); + const mailboxes = resolveActionMailboxes(); // Check if we already have this thread cached const cachedEmails = threadEmailsCache.get(threadId); @@ -1861,7 +2173,7 @@ export const useEmailStore = create((set, get) => ({ const accountId = mailbox?.isShared ? mailbox.accountId : undefined; // Fetch all emails in the thread - const emails = await client.getThreadEmails(threadId, accountId); + const emails = await resolveActionClient(client).getThreadEmails(threadId, accountId); // Update cache const newCache = new Map(get().threadEmailsCache); @@ -1896,8 +2208,12 @@ export const useEmailStore = create((set, get) => ({ // Mailbox management createMailbox: async (client, name, parentId) => { try { - await client.createMailbox(name, parentId); - await get().fetchMailboxes(client); + await resolveActionClient(client).createMailbox(name, parentId); + if (get().viewingAccountId) { + await refreshMailboxesForViewingAccount(client); + } else { + await get().fetchMailboxes(client); + } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to create folder' }); throw error; @@ -1906,12 +2222,24 @@ export const useEmailStore = create((set, get) => ({ renameMailbox: async (client, mailboxId, name) => { try { - await client.updateMailbox(mailboxId, { name }); - set({ - mailboxes: get().mailboxes.map(mb => - mb.id === mailboxId ? { ...mb, name } : mb - ), - }); + await resolveActionClient(client).updateMailbox(mailboxId, { name }); + const viewingId = get().viewingAccountId; + if (viewingId) { + set((state) => ({ + accountMailboxes: { + ...state.accountMailboxes, + [viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb => + mb.id === mailboxId ? { ...mb, name } : mb + ), + }, + })); + } else { + set({ + mailboxes: get().mailboxes.map(mb => + mb.id === mailboxId ? { ...mb, name } : mb + ), + }); + } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to rename folder' }); throw error; @@ -1920,18 +2248,27 @@ export const useEmailStore = create((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; @@ -1940,15 +2277,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; @@ -1958,7 +2300,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; @@ -1966,15 +2308,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', @@ -1986,11 +2341,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. diff --git a/stores/file-store.ts b/stores/file-store.ts index c757458b..115252e2 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -48,6 +48,8 @@ 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. */ + currentAccountId: string | null; clipboard: ClipboardState | null; uploadAbortController: AbortController | null; favorites: string[]; @@ -55,7 +57,9 @@ interface FileState { lastAction: UndoAction | null; // Actions - initClient: (client: IJMAPClient) => void; + initClient: (client: IJMAPClient, accountId?: string | null) => void; + /** Detach the current client and reset browse state. Used by the Pro shell to return to the cross-account picker. */ + clearClient: () => void; checkSupport: () => Promise; navigate: (parentId: string | null, name?: string) => Promise; navigateByPath: (path: string) => Promise; @@ -168,6 +172,7 @@ export const useFileStore = create((set, get) => ({ selectedResources: new Set(), uploadProgress: null, client: null, + currentAccountId: null, clipboard: null, uploadAbortController: null, lastAction: null, @@ -178,8 +183,25 @@ export const useFileStore = create((set, get) => ({ try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; } })(), - initClient: (client: IJMAPClient) => { - set({ client }); + initClient: (client: IJMAPClient, accountId?: string | null) => { + const patch: Partial = { client }; + if (accountId !== undefined) patch.currentAccountId = accountId; + set(patch); + }, + + clearClient: () => { + set({ + client: null, + currentAccountId: null, + supportsFiles: null, + pathStack: [{ id: null, name: '' }], + currentPath: '/', + currentParentId: null, + resources: [], + selectedResources: new Set(), + error: null, + isLoading: false, + }); }, checkSupport: async () => { 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 }), }), { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index b8f56d4d..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 ?? []); @@ -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 ), @@ -545,7 +549,7 @@ async function downloadPluginBundle(pluginId: string, bundleHash?: string): Prom // Ed25519 signature verification. Present on every server-managed bundle // since the signing module is server-side; refuse to persist a bundle // that fails verification. If the header is missing (older server / dev - // build with signing disabled) we log and allow — the SHA-256 hash check + // build with signing disabled) we log and allow - the SHA-256 hash check // at load time still catches transport corruption. const sig = res.headers.get('X-Bundle-Signature'); if (sig) { 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 b7d796c1..a77c4c78 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -44,6 +44,14 @@ export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s export type SendDelaySeconds = 0 | 10 | 30 | 60; export type ProtocolOpenMode = 'active-session' | 'new-tab'; +/** + * Settings that must never round-trip through the cross-device sync API. + * Decided per device and kept only in the local zustand-persist storage - + * a value already stored on the server (from a prior build) is ignored on + * import. + */ +const DEVICE_LOCAL_SETTING_KEYS = new Set(['proInterface']); + export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam'; export type HoverActionsMode = 'inline' | 'floating'; export type HoverActionsCorner = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; @@ -516,7 +524,8 @@ export const useSettingsStore = create()( toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher, showRailAccountList: state.showRailAccountList, - proInterface: state.proInterface, + // proInterface is intentionally omitted - it's a per-device choice + // (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced. enableUnifiedMailbox: state.enableUnifiedMailbox, senderFavicons: state.senderFavicons, showAvatarsInJunk: state.showAvatarsInJunk, @@ -565,6 +574,9 @@ export const useSettingsStore = create()( set({ sendDelaySeconds: 0 }); return; } + if (DEVICE_LOCAL_SETTING_KEYS.has(key)) { + return; + } set({ [key]: settings[key] }); } }); @@ -852,7 +864,7 @@ if (typeof window !== 'undefined') { syncWarn('Settings sync endpoint returned 404, disabling sync'); syncEnabled = false; } else if (res.status === 403) { - // Identity mismatch — current session cookies don't match the + // Identity mismatch - current session cookies don't match the // username/serverUrl we're syncing for (common in dev mock mode where // no stalwart-context cookie is written, or when rememberMe is off). // Retrying won't help for this session; disable to stop the noise. 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 */ }