Merge branch 'main' into feature/scheduled-send

# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/layout/sidebar.tsx
#	stores/email-store.ts
#	stores/settings-store.ts
This commit is contained in:
Lucas Gaitzsch
2026-05-22 12:31:06 +02:00
155 changed files with 4702 additions and 869 deletions
+78
View File
@@ -1,5 +1,83 @@
# Changelog # 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) ## 1.6.7 (2026-05-17)
### Features ### Features
+1 -1
View File
@@ -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) [![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) [![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) [![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/) [![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
+1 -1
View File
@@ -1 +1 @@
1.6.7 1.7.0
+10
View File
@@ -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();
}
@@ -90,7 +90,7 @@ function OAuthCallbackInner() {
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) { if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
// Drive /api/auth/sso/complete directly so we can read the tokens // 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 // wire up the webmail auth store, which isn't useful here. The
// server's mobile-flow branch (keyed on the pending cookie) skips // server's mobile-flow branch (keyed on the pending cookie) skips
// the refresh-token cookie write for the same reason. // the refresh-token cookie write for the same reason.
@@ -17,7 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { toast } from "@/stores/toast-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 { Button } from "@/components/ui/button";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; 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 { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils"; import { getEventStartDate } from "@/lib/calendar-utils";
@@ -76,7 +77,13 @@ export default function CalendarPage() {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action"); const tWebcalAction = useTranslations("calendar.webcal_action");
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded(); 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 { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -257,12 +264,17 @@ export default function CalendarPage() {
return subscribeToPendingWebcal(openPendingWebcal); return subscribeToPendingWebcal(openPendingWebcal);
}, [isAuthenticated, client, handleWebcalProtocolRequest]); }, [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(() => { useEffect(() => {
if (isEmbedded) return;
if (client && !hasFetched.current) { if (client && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
fetchCalendars(client); fetchCalendars(client);
} }
}, [client, fetchCalendars]); }, [client, fetchCalendars, isEmbedded]);
// Auto-refresh iCal subscriptions // Auto-refresh iCal subscriptions
useEffect(() => { useEffect(() => {
@@ -331,10 +343,21 @@ export default function CalendarPage() {
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]); }, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
useEffect(() => { useEffect(() => {
if (isEmbedded) return;
if (client && calendars.length > 0 && dateRange) { if (client && calendars.length > 0 && dateRange) {
fetchEvents(client, dateRange.start, dateRange.end); 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(() => { const navigatePrev = useCallback(() => {
let next: Date; let next: Date;
@@ -406,6 +429,8 @@ export default function CalendarPage() {
setMobileReturnToMonth(true); setMobileReturnToMonth(true);
setViewMode("day"); setViewMode("day");
} }
// Close the narrow-pane sidebar overlay after the user picks a date.
setNarrowSidebarOpen(false);
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]); }, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => { const navigateBackToMonth = useCallback(() => {
@@ -556,12 +581,15 @@ export default function CalendarPage() {
}, [events, client]); }, [events, client]);
const refetchCurrentRange = useCallback(async () => { const refetchCurrentRange = useCallback(async () => {
if (!client) return; if (!client || !activeAccountId) return;
const { dateRange: currentRange } = useCalendarStore.getState(); const { dateRange: currentRange } = useCalendarStore.getState();
if (currentRange) { if (!currentRange) return;
await fetchEvents(client, currentRange.start, currentRange.end); 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) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh calendar data via JMAP instead of reloading the page. // and refresh calendar data via JMAP instead of reloading the page.
@@ -569,8 +597,11 @@ export default function CalendarPage() {
enabled: isAuthenticated && !!client, enabled: isAuthenticated && !!client,
onRefresh: async () => { onRefresh: async () => {
if (!client) return; if (!client) return;
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
: fetchCalendars(client);
await Promise.all([ await Promise.all([
fetchCalendars(client), calendarRefresh,
refetchCurrentRange(), refetchCurrentRange(),
refreshAllSubscriptions(client), refreshAllSubscriptions(client),
]); ]);
@@ -1221,7 +1252,7 @@ export default function CalendarPage() {
return ( return (
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot /> <AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}> <div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail (hidden when embedded in Pro shell) */} {/* Left Navigation Rail (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && ( {!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -1242,15 +1273,31 @@ export default function CalendarPage() {
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)} )}
{/* Sidebar - full height */} {/* Narrow-pane backdrop: dim and close overlay sidebar */}
{!isMobile && !inlineApp && ( {isNarrow && narrowSidebarOpen && !inlineApp && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
{!inlineApp && (
<> <>
<div <div
className={cn( className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3", "border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300" !isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)
)} )}
style={{ width: `${calSidebarWidth}px` }} style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
> >
<MiniCalendar <MiniCalendar
selectedDate={selectedDate} selectedDate={selectedDate}
@@ -1311,8 +1358,10 @@ export default function CalendarPage() {
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)} onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client} client={client}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/> />
</div> </div>
{!isNarrow && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
@@ -1322,6 +1371,7 @@ export default function CalendarPage() {
}} }}
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }} onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/> />
)}
</> </>
)} )}
@@ -1343,6 +1393,7 @@ export default function CalendarPage() {
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks} enableCalendarTasks={enableCalendarTasks}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/> />
<div <div
@@ -2,6 +2,8 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { ArrowLeft, Users, AlertTriangle } from "lucide-react"; import { ArrowLeft, Users, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -27,8 +29,9 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types"; import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -92,12 +95,26 @@ export default function ContactsPage() {
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null); const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null); const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined); const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
const [returnToEmail, setReturnToEmail] = useState(false);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null); const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false); const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded(); 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) // Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => { const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -134,12 +151,41 @@ export default function ContactsPage() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [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(() => { useEffect(() => {
if (isEmbedded) return;
if (client && supportsSync && !hasFetched.current) { if (client && supportsSync && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
fetchContacts(client); 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) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page. // and refresh contacts via JMAP instead of reloading the page.
@@ -147,6 +193,17 @@ export default function ContactsPage() {
enabled: isAuthenticated && !!client && supportsSync, enabled: isAuthenticated && !!client && supportsSync,
onRefresh: async () => { onRefresh: async () => {
if (!client) return; 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); await fetchContacts(client);
}, },
}); });
@@ -198,6 +255,7 @@ export default function ContactsPage() {
} else { } else {
setSelectedGroupId(null); setSelectedGroupId(null);
} }
setNarrowSidebarOpen(false);
}, [clearSelection]); }, [clearSelection]);
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => { const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
@@ -340,8 +398,14 @@ export default function ContactsPage() {
toast.success(t("toast.created")); toast.success(t("toast.created"));
} }
setDefaultBookIdForCreate(undefined); setDefaultBookIdForCreate(undefined);
setCreatePrefill(undefined);
if (returnToEmail) {
setReturnToEmail(false);
router.back();
return;
}
setView("list"); setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]); }, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => { const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
if (!selectedContact) return; if (!selectedContact) return;
@@ -358,6 +422,14 @@ export default function ContactsPage() {
const handleCancel = () => { const handleCancel = () => {
setDefaultBookIdForCreate(undefined); 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") { if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list"); setView(selectedGroup ? "group-detail" : "list");
} else if (view === "bulk-add-to-group") { } else if (view === "bulk-add-to-group") {
@@ -529,7 +601,7 @@ export default function ContactsPage() {
const renderRightPanel = () => { const renderRightPanel = () => {
switch (view) { switch (view) {
case "create": case "create":
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />; return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit": case "edit":
if (!selectedContact) return null; if (!selectedContact) return null;
@@ -661,6 +733,12 @@ export default function ContactsPage() {
const showRightPanel = !isMobile || view !== "list"; const showRightPanel = !isMobile || view !== "list";
const mobileBackToList = () => { const mobileBackToList = () => {
if (returnToEmail) {
setReturnToEmail(false);
setCreatePrefill(undefined);
router.back();
return;
}
setView("list"); setView("list");
clearSelection(); clearSelection();
}; };
@@ -689,18 +767,33 @@ export default function ContactsPage() {
{inlineApp && ( {inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)} )}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}> <div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
{/* Narrow-pane backdrop for the overlay categories sidebar */}
{isNarrow && narrowSidebarOpen && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{showListPanel && ( {showListPanel && (
<> <>
{/* Panel 1: Categories sidebar */} {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
{!isMobile && ( {(!isMobile || isNarrow) && (
<> <>
<div <div
className={cn( className={cn(
"border-r border-border flex flex-col flex-shrink-0", "border-r border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300" !isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)
)} )}
style={{ width: `${sidebarWidth}px` }} style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
> >
<ContactsSidebar <ContactsSidebar
groups={groups} groups={groups}
@@ -737,8 +830,10 @@ export default function ContactsPage() {
} }
} : undefined} } : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)} onRenameKeyword={(kw) => setRenamingKeyword(kw)}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/> />
</div> </div>
{!isNarrow && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
@@ -748,6 +843,7 @@ export default function ContactsPage() {
}} }}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/> />
)}
</> </>
)} )}
@@ -780,6 +876,7 @@ export default function ContactsPage() {
onEditContact={handleEditContact} onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact} onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup} onAddContactToGroup={handleAddContactToGroup}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/> />
</div> </div>
@@ -809,7 +906,7 @@ export default function ContactsPage() {
className="touch-manipulation" className="touch-manipulation"
> >
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_contacts")} {returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button> </Button>
</div> </div>
)} )}
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
@@ -33,6 +34,9 @@ export default function FilesPage() {
const t = useTranslations("files"); const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); 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 { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
@@ -130,13 +134,18 @@ export default function FilesPage() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [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(() => { useEffect(() => {
if (isAuthenticated && client && !hasFetched.current) { if (!isAuthenticated || !client || hasFetched.current) return;
hasFetched.current = true; hasFetched.current = true;
initClient(client); 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) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page. // and refresh files via JMAP instead of reloading the page.
@@ -160,6 +169,17 @@ export default function FilesPage() {
}, [storeClient, supportsFiles, checkSupport, navigate]); }, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => { 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) { if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder) // Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || ''); navigate(resourceId, path.split('/').pop() || '');
@@ -167,7 +187,7 @@ export default function FilesPage() {
// Path-based navigation (breadcrumbs, favorites, recent files) // Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path); navigateByPath(path);
} }
}, [navigate, navigateByPath]); }, [navigate, navigateByPath, isEmbedded]);
const handleCreateFolder = useCallback(async (name: string) => { const handleCreateFolder = useCallback(async (name: string) => {
try { try {
@@ -374,6 +394,38 @@ export default function FilesPage() {
setShowDetails(v => !v); 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; if (!isAuthenticated) return null;
return ( return (
@@ -401,7 +453,7 @@ export default function FilesPage() {
)} )}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}> <div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
{folderLayout !== "sidebar" && ( {folderLayout !== "sidebar" && !isEmbedded && (
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}> <div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
@@ -479,6 +531,10 @@ export default function FilesPage() {
showDetails={showDetails} showDetails={showDetails}
onToggleDetails={handleToggleDetails} onToggleDetails={handleToggleDetails}
detailResource={detailResource} detailResource={detailResource}
accountFolders={accountFolders}
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
/> />
</div> </div>
)} )}
@@ -6,6 +6,7 @@ import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-p
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider"; import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
import { TourProvider } from "@/components/tour/tour-provider"; import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-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 { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { locales } from "@/i18n/routing"; import { locales } from "@/i18n/routing";
@@ -36,6 +37,7 @@ export default async function LocaleLayout({
<EmbeddedBridgeProvider> <EmbeddedBridgeProvider>
<TourProvider> <TourProvider>
<ProtocolLaunchHandlerProvider> <ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
{children} {children}
<PluginDialogHost /> <PluginDialogHost />
<PluginConsentDialog /> <PluginConsentDialog />
@@ -351,7 +351,7 @@ export default function LoginPage() {
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
// In mobile-handoff mode the callback page needs to know it should // In mobile-handoff mode the callback page needs to know it should
// redirect into the app rather than into /mail. Stash the params in // 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. // pending cookie carries the authoritative copy server-side too.
if (isMobileHandoff) { if (isMobileHandoff) {
try { try {
@@ -623,7 +623,7 @@ export default function LoginPage() {
saveUsername(formData.username); saveUsername(formData.username);
if (isMobileHandoff) { if (isMobileHandoff) {
// The isAuthenticated effect handles the redirect; nothing else to // 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; return;
} }
router.push('/'); router.push('/');
@@ -51,6 +51,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIdentitySync } from "@/hooks/use-identity-sync"; import { useIdentitySync } from "@/hooks/use-identity-sync";
import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProTabStore } from "@/stores/pro-tab-store"; import { useProTabStore } from "@/stores/pro-tab-store";
import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
@@ -300,8 +301,16 @@ export default function Home() {
batchMarkAsRead, batchMarkAsRead,
batchMarkAsSpam, batchMarkAsSpam,
batchUndoSpam, batchUndoSpam,
accountMailboxes,
viewingAccountId,
selectAccountMailbox,
setViewingAccount,
} = useEmailStore(); } = 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 enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const delayedSendSupported = client?.hasDelayedSend() ?? true; const delayedSendSupported = client?.hasDelayedSend() ?? true;
const activeEmails = isScheduledView ? scheduledEmails : emails; 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 // 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 // than one account is connected. Runs whenever the set of connected accounts
// or the primary account's mailboxes change (a proxy for "something worth // 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(() => { useEffect(() => {
if (!enableUnifiedMailbox || !isAuthenticated || !client) return; if (!enableUnifiedMailbox && !isEmbedded) return;
if (!isAuthenticated || !client) return;
const built = buildUnifiedAccounts(); const built = buildUnifiedAccounts();
if (built.length < 2) return; if (built.length < 2) return;
populateUnifiedAccountMailboxes(built).then((populated) => { populateUnifiedAccountMailboxes(built).then((populated) => {
refreshUnifiedCounts(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 // System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or // here with `?email=<id>` (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) => { const handleMailboxSelect = async (mailboxId: string) => {
if (mailboxId === SCHEDULED_MAILBOX_ID) { if (mailboxId === SCHEDULED_MAILBOX_ID) {
if (!delayedSendSupported) { if (!delayedSendSupported) {
@@ -1906,7 +1946,6 @@ export default function Home() {
const handleSearch = async (query: string) => { const handleSearch = async (query: string) => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
setSearchQuery(query); setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) { if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client); await advancedSearch(client);
@@ -1918,14 +1957,25 @@ export default function Home() {
const handleClearSearch = async () => { const handleClearSearch = async () => {
setSearchQuery(""); setSearchQuery("");
clearSearchFilters(); 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); await fetchEmails(client, selectedMailbox);
} }
}; };
const handleAdvancedSearch = async () => { const handleAdvancedSearch = async () => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
await advancedSearch(client); await advancedSearch(client);
}; };
@@ -1935,9 +1985,9 @@ export default function Home() {
clearTimeout(advancedSearchDebounceRef.current); clearTimeout(advancedSearchDebounceRef.current);
} }
advancedSearchDebounceRef.current = setTimeout(() => { advancedSearchDebounceRef.current = setTimeout(() => {
if (client && !isUnifiedView) advancedSearch(client); if (client) advancedSearch(client);
}, 300); }, 300);
}, [client, advancedSearch, isUnifiedView]); }, [client, advancedSearch]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -2089,7 +2139,7 @@ export default function Home() {
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent); const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent; const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout);
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent; const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching // Handle email selection with mobile view switching
@@ -2365,6 +2415,10 @@ export default function Home() {
} }
}} }}
onSidebarClose={() => setSidebarOpen(false)} onSidebarClose={() => setSidebarOpen(false)}
multiAccountMode={isEmbedded}
accountMailboxes={accountMailboxes}
viewingAccountId={viewingAccountId}
onAccountMailboxSelect={handleAccountMailboxSelect}
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
@@ -2748,7 +2802,7 @@ export default function Home() {
</div> </div>
{/* Email list resize handle (desktop only) */} {/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && ( {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
@@ -9,6 +9,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useDeviceDetection } from "@/hooks/use-media-query"; import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded"; import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { PaneSizeContext } from "@/hooks/use-pane-size"; 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 { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import MailPage from "@/app/[locale]/page"; import MailPage from "@/app/(main)/[locale]/page";
import CalendarPage from "@/app/[locale]/calendar/page"; import CalendarPage from "@/app/(main)/[locale]/calendar/page";
import ContactsPage from "@/app/[locale]/contacts/page"; import ContactsPage from "@/app/(main)/[locale]/contacts/page";
import FilesPage from "@/app/[locale]/files/page"; import FilesPage from "@/app/(main)/[locale]/files/page";
import SettingsPage from "@/app/[locale]/settings/page"; import SettingsPage from "@/app/(main)/[locale]/settings/page";
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body"; import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
import { ProEmailTabBody } from "@/components/pro/pro-email-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) { function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
const paneRef = useRef<HTMLDivElement | null>(null); const paneRef = useRef<HTMLDivElement | null>(null);
// Measured pane width, published to children via PaneSizeContext so that // Measured pane width, published to children via PaneSizeContext so that
// useDeviceDetection / useIsMobile / etc. branch on pane width not full // useDeviceDetection / useIsMobile / etc. branch on pane width - not full
// viewport and inner pages collapse to their mobile/tablet layouts when // viewport - and inner pages collapse to their mobile/tablet layouts when
// the pane is narrow. // the pane is narrow.
const [paneWidth, setPaneWidth] = useState<number | null>(null); const [paneWidth, setPaneWidth] = useState<number | null>(null);
@@ -128,6 +129,7 @@ export default function ProHome() {
const authLoading = useAuthStore((s) => s.isLoading); const authLoading = useAuthStore((s) => s.isLoading);
const quota = useEmailStore((s) => s.quota); const quota = useEmailStore((s) => s.quota);
const isPushConnected = useEmailStore((s) => s.isPushConnected); const isPushConnected = useEmailStore((s) => s.isPushConnected);
const proInterface = useSettingsStore((s) => s.proInterface);
const tabs = useProTabStore((s) => s.tabs); const tabs = useProTabStore((s) => s.tabs);
const activeMainTabId = useProTabStore((s) => s.activeTabId); const activeMainTabId = useProTabStore((s) => s.activeTabId);
@@ -165,10 +167,14 @@ export default function ProHome() {
}, [initialCheckDone, isAuthenticated, authLoading]); }, [initialCheckDone, isAuthenticated, authLoading]);
useEffect(() => { 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("/"); window.location.replace("/");
} }
}, [initialCheckDone, isMobile, isTablet]); }, [initialCheckDone, isMobile, isTablet, proInterface]);
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]); const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [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 // Stable keys are essential: when the split collapses, the row's child
// list goes from [splitPane, divider, mainPane] (or the leading variant) // list goes from [splitPane, divider, mainPane] (or the leading variant)
// to [mainPane]. Without keys, React would reuse the Pane instance at // 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- // which strands the main pane's ResizeObserver/paneWidth on a now-
// unmounted DOM node and reparents the mail tab body (causing remount // unmounted DOM node and reparents the mail tab body (causing remount
// + stale "still-narrow" measurements after the split is closed). // + stale "still-narrow" measurements after the split is closed).
@@ -310,7 +316,7 @@ export default function ProHome() {
<EmbeddedContext.Provider value={true}> <EmbeddedContext.Provider value={true}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]"> <div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Leftmost Navigation Rail identical to the standard layout */} {/* Leftmost Navigation Rail - identical to the standard layout */}
<div <div
className="w-14 bg-secondary flex flex-col flex-shrink-0" className="w-14 bg-secondary flex flex-col flex-shrink-0"
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }} style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
@@ -351,7 +357,7 @@ export default function ProHome() {
onDragStateChange={setIsTabDragging} onDragStateChange={setIsTabDragging}
/> />
{/* Panes container accepts body drops for split/move. */} {/* Panes container - accepts body drops for split/move. */}
<div <div
className="relative flex flex-row flex-1 overflow-hidden min-w-0" className="relative flex flex-row flex-1 overflow-hidden min-w-0"
onDragOver={handleBodyDragOver} onDragOver={handleBodyDragOver}
@@ -161,6 +161,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.account.email', 'settings.account.email',
'settings.account.server', 'settings.account.server',
'settings.account.storage', 'settings.account.storage',
'settings.account.accounts',
], ],
language: ['settings.appearance.language'], language: ['settings.appearance.language'],
notifications: ['settings.notifications'], notifications: ['settings.notifications'],
@@ -228,7 +229,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
// Extra English keywords per tab so common search terms hit even when the // Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word. // translation doesn't contain the literal word.
const tabKeywords: Record<Tab, string> = { const tabKeywords: Record<Tab, string> = {
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', language: 'locale region timezone date time format',
notifications: 'sound alert push badge', notifications: 'sound alert push badge',
appearance: 'theme dark light font size accent color animation density', 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 installedPlugins = usePluginStore((s) => s.plugins);
const installedThemes = useThemeStore((s) => s.installedThemes); const installedThemes = useThemeStore((s) => s.installedThemes);
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps); 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 // Build a per-tab haystack for fulltext search and a list of sub-results
// (individual settings) per tab. Sub-results come from translation entries // (individual settings) per tab. Sub-results come from translation entries
@@ -865,6 +867,7 @@ export default function SettingsPage() {
)} )}
style={{ width: `${settingsSidebarWidth}px` }} style={{ width: `${settingsSidebarWidth}px` }}
> >
{!proInterface && (
<div className="p-4 border-b border-border"> <div className="p-4 border-b border-border">
<Button <Button
variant="ghost" variant="ghost"
@@ -876,6 +879,7 @@ export default function SettingsPage() {
{t('back_to_mail')} {t('back_to_mail')}
</Button> </Button>
</div> </div>
)}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs"> <div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-3 pt-1 pb-1"> <div className="px-3 pt-1 pb-1">
@@ -6,7 +6,7 @@ import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
// Sensitive keys (sessionSecret, oauthClientSecret) come back with // 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. // the raw secret to the client.
value?: unknown; value?: unknown;
source: 'admin' | 'env' | 'default'; source: 'admin' | 'env' | 'default';
@@ -271,7 +271,7 @@ export function AuthTab() {
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} /> <Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} /> <Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} /> <Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved type to replace)' : undefined} /> <Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" /> <Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" /> <Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." /> <Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
@@ -25,6 +25,20 @@ const TEXT_FIELDS = [
{ key: 'loginWebsiteUrl', label: 'Company Website URL' }, { 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() { export function BrandingTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({}); const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({}); const [edits, setEdits] = useState<Record<string, unknown>>({});
@@ -262,6 +276,142 @@ export function BrandingTab() {
</div> </div>
</div> </div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
</div>
<div className="divide-y divide-border">
{PWA_IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => 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"
/>
<input
ref={el => { 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 = '';
}}
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title="Upload file"
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
{isUploadedFile(field.key) && (
<button
onClick={() => handleDeleteUpload(field.key)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Remove uploaded file"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{currentValue(field.key) && (
<div className="mt-2 flex items-center gap-2">
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img
src={currentValue(field.key)}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
</div>
)}
</div>
))}
{PWA_TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => 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' && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
{PWA_COLOR_FIELDS.map(field => {
const value = currentValue(field.key) || field.defaultValue;
return (
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
onChange={(e) => 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"
/>
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => 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' && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="border border-border rounded-lg"> <div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Company Information</h2> <h2 className="text-sm font-medium text-foreground">Company Information</h2>
@@ -2,9 +2,9 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link'; 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 { 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'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -21,6 +21,7 @@ interface Extension {
minAppVersion: string | null; minAppVersion: string | null;
latestVersion: string | null; latestVersion: string | null;
installed: boolean; installed: boolean;
installedVersion: string | null;
iconUrl: string | null; iconUrl: string | null;
bannerUrl: string | null; bannerUrl: string | null;
author: { author: {
@@ -104,6 +105,8 @@ export function MarketplaceTab() {
}); });
return; return;
} }
const isUpdate = ext.installed;
const targetVersion = ext.latestVersion || '1.0.0';
setInstalling(ext.slug); setInstalling(ext.slug);
setMessage(null); setMessage(null);
@@ -113,7 +116,7 @@ export function MarketplaceTab() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
slug: ext.slug, slug: ext.slug,
version: ext.latestVersion || '1.0.0', version: targetVersion,
type: ext.type, type: ext.type,
}), }),
}); });
@@ -122,13 +125,22 @@ export function MarketplaceTab() {
if (res.ok) { if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); setMessage({
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); 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 { } else {
setMessage({ type: 'error', text: data.error || 'Installation failed' }); setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Installation failed - network error' }); setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally { } finally {
setInstalling(null); setInstalling(null);
} }
@@ -270,6 +282,11 @@ function ExtensionCard({
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
const versionMismatch = !!extension.minAppVersion const versionMismatch = !!extension.minAppVersion
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion); && !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
const updateAvailable = extension.installed
&& !!extension.installedVersion
&& !!extension.latestVersion
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
&& !versionMismatch;
return ( return (
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors"> <div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
@@ -359,8 +376,25 @@ function ExtensionCard({
</Link> </Link>
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap"> <div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
{extension.installed ? ( {extension.installed && updateAvailable ? (
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"> <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<ArrowUpCircle className="w-3 h-3" />
)}
Update to v{extension.latestVersion}
</button>
) : extension.installed ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
Installed Installed
</span> </span>
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { import {
ArrowLeft, ArrowLeft,
ArrowUpCircle,
Download, Download,
Loader2, Loader2,
Puzzle, Puzzle,
@@ -21,7 +22,7 @@ import {
ChevronUp, ChevronUp,
} from 'lucide-react'; } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; 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'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -73,6 +74,7 @@ interface PreviewData {
error: string | null; error: string | null;
}; };
installed: boolean; installed: boolean;
installedVersion: string | null;
} }
const RISKY_PERMISSIONS = new Set([ const RISKY_PERMISSIONS = new Set([
@@ -118,6 +120,8 @@ export default function MarketplacePreviewPage() {
async function handleInstall() { async function handleInstall() {
if (!data) return; if (!data) return;
const isUpdate = data.installed;
const targetVersion = data.extension.latestVersion || '1.0.0';
setInstalling(true); setInstalling(true);
setMessage(null); setMessage(null);
try { try {
@@ -126,20 +130,25 @@ export default function MarketplacePreviewPage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
slug: data.extension.slug, slug: data.extension.slug,
version: data.extension.latestVersion || '1.0.0', version: targetVersion,
type: data.extension.type, type: data.extension.type,
}), }),
}); });
const body = await res.json(); const body = await res.json();
if (res.ok) { if (res.ok) {
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : ''; const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` }); setMessage({
setData(prev => prev ? { ...prev, installed: true } : prev); 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 { } else {
setMessage({ type: 'error', text: body.error || 'Installation failed' }); setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Installation failed - network error' }); setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally { } finally {
setInstalling(false); setInstalling(false);
} }
@@ -204,6 +213,11 @@ export default function MarketplacePreviewPage() {
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || []; const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined; const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion); 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 ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
@@ -248,11 +262,22 @@ export default function MarketplacePreviewPage() {
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1> <h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />} {ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && ( {data.installed && !updateAvailable && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"> <span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> Installed <Check className="w-3 h-3" /> Installed
</span> </span>
)} )}
{data.installed && updateAvailable && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
>
<ArrowUpCircle className="w-3 h-3" /> Update available
</span>
)}
</div> </div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap"> <div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${ <span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
@@ -279,6 +304,17 @@ export default function MarketplacePreviewPage() {
<div className="flex flex-wrap items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 shrink-0">
{data.installed ? ( {data.installed ? (
<> <>
{updateAvailable && (
<button
onClick={handleInstall}
disabled={installing || !!bundle.error}
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
Update to v{ext.latestVersion}
</button>
)}
<Link <Link
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'} href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors" className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
+1 -1
View File
@@ -5,7 +5,7 @@ import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager"; import { configManager } from "@/lib/admin/config-manager";
import "./globals.css"; import "../globals.css";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -101,20 +101,17 @@ export default function SetupWizardPage() {
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG); const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
const [stepIndex, setStepIndex] = useState(0); const [stepIndex, setStepIndex] = useState(0);
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
// Detect synchronously on first client render so we don't flash the loading // Resolved in a post-mount effect, not at render, so the server-rendered
// screen before the warning appears. The session cookie is set with the // HTML (where window is absent) matches the client's first paint and
// Secure flag in production, which browsers silently drop over plain HTTP - // doesn't trip a hydration mismatch.
// every subsequent step call then 401s with "Wizard session required". const [insecureContext, setInsecureContext] = useState(false);
const [insecureContext] = useState<boolean>(detectInsecureContext); const [insecureAcknowledged, setInsecureAcknowledged] = useState(false);
useEffect(() => {
setInsecureContext(detectInsecureContext());
}, []);
// ─── Initial status load ──────────────────────────────────────────────── // ─── Initial status load ────────────────────────────────────────────────
useEffect(() => { 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; let cancelled = false;
(async () => { (async () => {
try { try {
@@ -152,7 +149,7 @@ export default function SetupWizardPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [router, insecureContext]); }, [router]);
// ─── Token submit (welcome step) ──────────────────────────────────────── // ─── Token submit (welcome step) ────────────────────────────────────────
async function submitToken(token: string) { async function submitToken(token: string) {
@@ -184,8 +181,8 @@ export default function SetupWizardPage() {
} }
// ─── Render shell ─────────────────────────────────────────────────────── // ─── Render shell ───────────────────────────────────────────────────────
if (insecureContext) { if (insecureContext && !insecureAcknowledged) {
return <InsecureContextScreen />; return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
} }
if (bootstrapping) { if (bootstrapping) {
@@ -362,7 +359,7 @@ function CompletedScreen() {
); );
} }
function InsecureContextScreen() { function InsecureContextScreen({ onContinue }: { onContinue: () => void }) {
const httpsUrl = const httpsUrl =
typeof window !== 'undefined' typeof window !== 'undefined'
? `https://${window.location.host}${window.location.pathname}${window.location.search}` ? `https://${window.location.host}${window.location.pathname}${window.location.search}`
@@ -373,29 +370,29 @@ function InsecureContextScreen() {
<div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4"> <div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4">
<ShieldAlert className="h-6 w-6" /> <ShieldAlert className="h-6 w-6" />
</div> </div>
<h1 className="text-xl font-semibold">HTTPS required for setup</h1> <h1 className="text-xl font-semibold">You&apos;re running setup over plain HTTP</h1>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2 leading-relaxed">
The setup wizard signs you in with a <code className="font-mono text-xs">Secure</code> cookie, The setup token and admin password you enter here will travel in cleartext.
which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it.
step to fail with <em>Wizard session required</em>.
</p> </p>
</div> </div>
<div className="mt-5 text-left text-sm text-muted-foreground space-y-2"> <div className="mt-6 space-y-2">
<p className="font-medium text-foreground">To continue, do one of the following:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Reach this page over HTTPS (terminate TLS on the container or a reverse proxy in front of it).</li>
<li>If you already have a reverse proxy, make sure it forwards to the webmail and forwards the
<code className="font-mono text-xs"> X-Forwarded-Proto</code> header.</li>
</ul>
</div>
{httpsUrl && ( {httpsUrl && (
<a <a
href={httpsUrl} href={httpsUrl}
className="mt-6 block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90" className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
> >
Open over HTTPS Try HTTPS
</a> </a>
)} )}
<button
type="button"
onClick={onContinue}
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
>
Continue over HTTP
</button>
</div>
</CenteredCard> </CenteredCard>
); );
} }
@@ -743,6 +740,21 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
</div> </div>
</div> </div>
)} )}
{isPrivateOrLocalHostUrl(config.jmapServerUrl) && (
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
<AlertTriangle className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 self-center">
<p className="text-sm font-medium text-foreground leading-relaxed">
This URL only resolves locally.
</p>
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
Mail is fetched directly from the user&apos;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. <code className="font-mono text-xs">https://mail.example.com</code>) in production.
</p>
</div>
</div>
)}
{probe && probe.url === config.jmapServerUrl && ( {probe && probe.url === config.jmapServerUrl && (
probe.status === 'jmap_detected' ? ( probe.status === 'jmap_detected' ? (
<div className="mt-2 p-3 rounded-xl border border-success/20 bg-success/5 flex items-start gap-3"> <div className="mt-2 p-3 rounded-xl border border-success/20 bg-success/5 flex items-start gap-3">
@@ -1782,13 +1794,52 @@ function isInsecureHttpUrl(url: string): boolean {
return /^http:\/\//i.test(url.trim()); 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 { function detectInsecureContext(): boolean {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
if (window.location.protocol !== 'http:') return false; if (window.location.protocol !== 'http:') return false;
// Browsers treat localhost/loopback as "potentially trustworthy" and accept // 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; 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 false;
} }
return true; return true;
+32
View File
@@ -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 (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{ margin: 0, padding: 0, background: 'transparent' }}
>
{children}
</body>
</html>
);
}
+10
View File
@@ -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 <SandboxRuntime />;
}
+1
View File
@@ -24,6 +24,7 @@ const ALLOWED_MIME_TYPES = new Set([
/** Slots that correspond to branding config keys */ /** Slots that correspond to branding config keys */
const VALID_SLOTS = new Set([ const VALID_SLOTS = new Set([
'faviconUrl', 'faviconUrl',
'pwaIconUrl',
'appLogoLightUrl', 'appLogoLightUrl',
'appLogoDarkUrl', 'appLogoDarkUrl',
'loginLogoLightUrl', 'loginLogoLightUrl',
+1 -1
View File
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers'; import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger'; 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 // can warn about a placeholder session secret without us ever returning the
// raw value to the client. // raw value to the client.
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']); const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
+16 -8
View File
@@ -7,8 +7,12 @@ import {
} from '@/lib/admin/plugin-registry'; } from '@/lib/admin/plugin-registry';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; 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<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
const MAX_PREVIEW_SOURCE_LEN = 100_000; const MAX_PREVIEW_SOURCE_LEN = 100_000;
@@ -27,9 +31,10 @@ export async function GET(
if ('error' in result) return result.error; if ('error' in result) return result.error;
const { slug } = await params; const { slug } = await params;
const directoryUrl = await getDirectoryUrl();
// 1. Extension metadata + screenshots + theme previews from the directory // 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(), { const detailRes = await fetch(detailUrl.toString(), {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
@@ -63,7 +68,7 @@ export async function GET(
try { try {
const bundleUrl = new URL( const bundleUrl = new URL(
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`, `/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
DIRECTORY_URL, directoryUrl,
); );
const bundleRes = await fetch(bundleUrl.toString(), { const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
@@ -144,14 +149,16 @@ export async function GET(
getPluginRegistry(), getPluginRegistry(),
getThemeRegistry(), getThemeRegistry(),
]); ]);
const installed = type === 'theme' const installedEntry = type === 'theme'
? themeRegistry.themes.some((t) => t.id === slug) ? themeRegistry.themes.find((t) => t.id === slug)
: pluginRegistry.plugins.some((p) => p.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). // 4. Build screenshot URLs (proxy through the directory's public files endpoint).
const screenshots = Array.isArray(extension.screenshots) const screenshots = Array.isArray(extension.screenshots)
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({ ? (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, altText: s.altText ?? null,
})) }))
: []; : [];
@@ -170,7 +177,7 @@ export async function GET(
const fileUrl = (path: unknown): string | null => const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() ? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null; : null;
return NextResponse.json( return NextResponse.json(
@@ -206,6 +213,7 @@ export async function GET(
error: bundleError, error: bundleError,
}, },
installed, installed,
installedVersion,
}, },
{ headers: { 'Cache-Control': 'no-store' } }, { headers: { 'Cache-Control': 'no-store' } },
); );
+80 -21
View File
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
import { import {
savePlugin, savePlugin,
saveTheme, saveTheme,
getPlugin,
getTheme,
getPluginRegistry, getPluginRegistry,
getThemeRegistry, getThemeRegistry,
type ServerPlugin, type ServerPlugin,
@@ -19,8 +21,12 @@ import {
import JSZip from 'jszip'; import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; 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<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
/** /**
* GET /api/admin/marketplace - Search/browse the extension directory * GET /api/admin/marketplace - Search/browse the extension directory
@@ -31,8 +37,9 @@ export async function GET(request: NextRequest) {
const result = await requireAdminAuth(request); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const directoryUrl = await getDirectoryUrl();
const { searchParams } = request.nextUrl; 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 // Forward all search params
for (const [key, value] of searchParams.entries()) { for (const [key, value] of searchParams.entries()) {
@@ -59,23 +66,32 @@ export async function GET(request: NextRequest) {
getThemeRegistry(), getThemeRegistry(),
]); ]);
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id)); const installedPluginVersions = new Map(
const installedThemes = new Set(themeRegistry.themes.map(t => t.id)); 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 => const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() ? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null; : null;
if (data.data) { if (data.data) {
data.data = data.data.map((ext: Record<string, unknown>) => ({ data.data = data.data.map((ext: Record<string, unknown>) => {
const slug = ext.slug as string;
const installedVersion = ext.type === 'theme'
? installedThemeVersions.get(slug) ?? null
: installedPluginVersions.get(slug) ?? null;
return {
...ext, ...ext,
iconUrl: fileUrl(ext.iconPath), iconUrl: fileUrl(ext.iconPath),
bannerUrl: fileUrl(ext.bannerPath), bannerUrl: fileUrl(ext.bannerPath),
installed: ext.type === 'theme' installed: installedVersion !== null,
? installedThemes.has(ext.slug as string) installedVersion,
: installedPlugins.has(ext.slug as string), };
})); });
} }
return NextResponse.json(data, { return NextResponse.json(data, {
@@ -108,7 +124,8 @@ export async function POST(request: NextRequest) {
} }
// Download the bundle from the directory // 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(), { const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
}); });
@@ -194,22 +211,43 @@ export async function POST(request: NextRequest) {
warnings.push(...sanitized.warnings); warnings.push(...sanitized.warnings);
} }
const existingTheme = await getTheme(resolvedId);
const isUpdate = existingTheme !== null;
const theme: ServerTheme = { const theme: ServerTheme = {
id: resolvedId, id: resolvedId,
name: (manifest.name as string) || slug, 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', author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '', description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'], variants: (manifest.variants as string[]) || ['light', 'dark'],
enabled: true, enabled: existingTheme?.enabled ?? true,
installedAt: now, ...(existingTheme?.forceEnabled !== undefined
? { forceEnabled: existingTheme.forceEnabled }
: {}),
installedAt: existingTheme?.installedAt ?? now,
updatedAt: now, updatedAt: now,
}; };
await saveTheme(theme, css); 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 { } else {
// Plugin installation // Plugin installation
// Read entrypoint JS // 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 = { const plugin: ServerPlugin = {
id: resolvedId, id: resolvedId,
name: (manifest.name as string) || slug, 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', author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '', description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook', type: (manifest.type as string) || 'hook',
permissions, permissions,
entrypoint, entrypoint,
enabled: true, enabled: existingPlugin?.enabled ?? true,
installedAt: now, ...(existingPlugin?.forceEnabled !== undefined
? { forceEnabled: existingPlugin.forceEnabled }
: {}),
installedAt: existingPlugin?.installedAt ?? now,
updatedAt: now, updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object' ...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
@@ -322,9 +368,22 @@ export async function POST(request: NextRequest) {
await savePlugin(plugin, code); await savePlugin(plugin, code);
invalidateFrameOriginsCache(); 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) { } catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
+10 -1
View File
@@ -240,10 +240,19 @@ export async function PATCH(request: NextRequest) {
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled; if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry'); const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
const updated = await updatePluginMeta(id, updates); let updated = await updatePluginMeta(id, updates);
if (!updated) { if (!updated) {
// 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 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
} }
updated = { ...devEntry.plugin, ...updates };
}
// Enable/disable changes the set of plugins contributing frame origins. // Enable/disable changes the set of plugins contributing frame origins.
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') { if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
+3 -3
View File
@@ -23,7 +23,7 @@ const IMPERSONATION_SLOT = 0;
/** /**
* Impersonation cookies deliberately omit Max-Age so the browser treats * 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 * closes the browser, not 30 days later. Impersonation is a temporary
* support handoff; a normal password login is the only thing that should * support handoff; a normal password login is the only thing that should
* survive a browser restart. * survive a browser restart.
@@ -48,7 +48,7 @@ function impersonationCookieOptions() {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const config = readImpersonationConfig(); const config = readImpersonationConfig();
if (!config) { 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 }); return new NextResponse('Not found', { status: 404 });
} }
@@ -112,7 +112,7 @@ export async function GET(request: NextRequest) {
authHeader, 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', { logger.info('Impersonation session granted', {
event: 'impersonation_granted', event: 'impersonation_granted',
jti: claims.jti, jti: claims.jti,
+1 -1
View File
@@ -74,7 +74,7 @@ export async function POST(request: NextRequest) {
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
// For the mobile handoff flow the tokens are handed back to the app // 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 // origin (the mobile browser tab disposes of the session after the
// redirect anyway, but the cookie would still get committed to 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). // user's main webmail session if they happened to be logged in there).
+1 -1
View File
@@ -77,7 +77,7 @@ export async function POST(request: NextRequest) {
// /complete handler reaches the same OAuth endpoint we used to authorize. // /complete handler reaches the same OAuth endpoint we used to authorize.
// Mobile params are captured here so /complete knows to return tokens to // Mobile params are captured here so /complete knows to return tokens to
// the caller (in the JSON response) instead of writing the usual server // 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 = { const pendingData = {
state, state,
code_verifier: codeVerifier, code_verifier: codeVerifier,
+1 -1
View File
@@ -7,7 +7,7 @@ import { logger } from '@/lib/logger';
* *
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the * Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
* sandboxed plugin loader can verify bundle signatures before evaluation. * 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 * 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 * deletes the on-disk PEM), but we keep it `no-store` for simplicity. The
+10 -1
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry'; import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
/** /**
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
*/ */
export async function GET() { export async function GET() {
try { try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([ const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
getPluginRegistry(), getPluginRegistry(),
getThemeRegistry(), getThemeRegistry(),
@@ -34,7 +39,11 @@ export async function GET() {
type: p.type, type: p.type,
permissions: p.permissions, permissions: p.permissions,
entrypoint: p.entrypoint, 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 // Content hash + updatedAt let clients detect re-uploads even when
// the manifest version is unchanged. // the manifest version is unchanged.
bundleHash: p.bundleHash, bundleHash: p.bundleHash,
+23 -6
View File
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp'; import sharp from 'sharp';
import path from 'node:path'; import path from 'node:path';
import { readFile } from 'node:fs/promises'; 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]); const VALID_SIZES = new Set([192, 512]);
// Cache resized images in memory to avoid reprocessing on every request // Cache resized images keyed by (size, source URL) so admin re-uploads or URL
const cache = new Map<number, Blob>(); // changes invalidate the prior render instead of serving stale bytes forever.
const cache = new Map<string, Blob>();
async function fetchSourceImage(iconUrl: string): Promise<Buffer> { async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
// Absolute URL (http/https) // Absolute URL (http/https)
@@ -16,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
return Buffer.from(await res.arrayBuffer()); return Buffer.from(await res.arrayBuffer());
} }
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
// 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 // Path relative to public/ directory
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, '')); const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
return readFile(publicPath); return readFile(publicPath);
@@ -32,7 +43,11 @@ export async function GET(
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 }); 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) { if (!iconUrl) {
return new NextResponse('No PWA icon configured', { status: 404 }); return new NextResponse('No PWA icon configured', { status: 404 });
} }
@@ -42,9 +57,11 @@ export async function GET(
'Cache-Control': 'public, max-age=86400', 'Cache-Control': 'public, max-age=86400',
}; };
const cacheKey = `${size}|${iconUrl}`;
try { try {
if (cache.has(size)) { if (cache.has(cacheKey)) {
return new NextResponse(cache.get(size)!, { headers: pngHeaders }); return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
} }
const sourceBuffer = await fetchSourceImage(iconUrl); const sourceBuffer = await fetchSourceImage(iconUrl);
@@ -56,7 +73,7 @@ export async function GET(
const ab = new ArrayBuffer(resized.byteLength); const ab = new ArrayBuffer(resized.byteLength);
new Uint8Array(ab).set(resized); new Uint8Array(ab).set(resized);
const blob = new Blob([ab], { type: 'image/png' }); const blob = new Blob([ab], { type: 'image/png' });
cache.set(size, blob); cache.set(cacheKey, blob);
return new NextResponse(blob, { headers: pngHeaders }); return new NextResponse(blob, { headers: pngHeaders });
} catch (err) { } catch (err) {
+1 -1
View File
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
try { try {
// 1. Provision the admin account. An admin.json file may already exist // 1. Provision the admin account. An admin.json file may already exist
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard // 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 // password as authoritative in that case. The finish route is gated
// by the bootstrap state + one-time setup token, so this is safe. // by the bootstrap state + one-time setup token, so this is safe.
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true }); const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
+1 -1
View File
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
} }
const response = NextResponse.json({ ok: true }); const response = NextResponse.json({ ok: true });
const attrs = buildSessionCookieAttributes(); const attrs = buildSessionCookieAttributes(request);
response.cookies.set(attrs.name, submitted, { response.cookies.set(attrs.name, submitted, {
httpOnly: attrs.httpOnly, httpOnly: attrs.httpOnly,
sameSite: attrs.sameSite, sameSite: attrs.sameSite,
+16 -9
View File
@@ -1,4 +1,5 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { configManager } from "@/lib/admin/config-manager";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,22 +22,28 @@ type ExtendedManifest = MetadataRoute.Manifest & {
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
const withBase = (p: string) => `${BASE_PATH}${p}`; const withBase = (p: string) => `${BASE_PATH}${p}`;
export default function manifest(): ExtendedManifest { export default async function manifest(): Promise<ExtendedManifest> {
await configManager.ensureLoaded();
const appName = const appName =
process.env.APP_NAME || configManager.get<string>("appName") ||
process.env.NEXT_PUBLIC_APP_NAME || process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail"; "Bulwark Webmail";
const shortName = process.env.APP_SHORT_NAME || appName; const shortName = configManager.get<string>("appShortName") || appName;
const description = const description =
process.env.APP_DESCRIPTION || configManager.get<string>("appDescription") ||
"A modern webmail client built for Stalwart Mail Server"; "A modern webmail client built for Stalwart Mail Server";
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff"; const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff"; const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs // If pwaIconUrl or faviconUrl was explicitly configured (admin override or
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs. // env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL); // 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 const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [ ? [
-17
View File
@@ -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 (
<html lang="en">
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
{children}
</body>
</html>
);
}
-7
View File
@@ -1,7 +0,0 @@
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
export const dynamic = 'force-static';
export default function PluginSandboxPage() {
return <SandboxRuntime />;
}
+178 -1
View File
@@ -2,19 +2,49 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslations } from "next-intl"; 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 { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types"; import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store"; import { useTaskStore } from "@/stores/task-store";
import { useAccountStore } from "@/stores/account-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface'; 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<string, { label: string; calendars: Calendar[] }>();
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 { interface CalendarSidebarPanelProps {
calendars: Calendar[]; calendars: Calendar[];
selectedCalendarIds: string[]; selectedCalendarIds: string[];
@@ -28,6 +58,12 @@ interface CalendarSidebarPanelProps {
onSubscribe?: () => void; onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void; onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null; 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({ export function CalendarSidebarPanel({
@@ -43,6 +79,7 @@ export function CalendarSidebarPanel({
onSubscribe, onSubscribe,
onEditSubscription, onEditSubscription,
client, client,
multiAccountMode,
}: CalendarSidebarPanelProps) { }: CalendarSidebarPanelProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription"); const tSub = useTranslations("calendar.subscription");
@@ -70,6 +107,26 @@ export function CalendarSidebarPanel({
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>(); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null); const [refreshingSubId, setRefreshingSubId] = useState<string | null>(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<Set<string>>(() => {
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 personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
const sharedAccountGroups = useMemo(() => { const sharedAccountGroups = useMemo(() => {
const shared = calendars.filter(c => c.isShared); const shared = calendars.filter(c => c.isShared);
@@ -84,6 +141,53 @@ export function CalendarSidebarPanel({
return Array.from(groups.values()); return Array.from(groups.values());
}, [calendars]); }, [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<string, Calendar[]>();
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) => { const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId); return icalSubscriptions.find(s => s.calendarId === calendarId);
}; };
@@ -268,6 +372,77 @@ export function CalendarSidebarPanel({
)} )}
</button> </button>
)} )}
{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 (
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
<button
onClick={() => toggleAccountGroup(group.key)}
className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
>
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-semibold text-foreground/90 truncate">
{group.label}
</span>
{isActive && onCreateCalendar && (
<span
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); onCreateCalendar(); }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onCreateCalendar();
}
}}
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={tMgmt('add_calendar')}
>
<Plus className="w-3 h-3" />
</span>
)}
</button>
{expanded && (
<div className="mt-1 pl-3">
{owned.length > 0 && (
<div>
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
{t('my_calendars')}
</div>
<div className="space-y-0.5">
{owned.map(renderCalendarItem)}
</div>
</div>
)}
{sharedGroups.map((sg) => (
<div key={`${group.key}-shared-${sg.label}`} className="mt-2">
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{sg.label}
</div>
<div className="space-y-0.5">
{sg.calendars.map(renderCalendarItem)}
</div>
</div>
))}
</div>
)}
</div>
);
})}
</>
) : (
<>
<div className="flex items-center justify-between mb-2 px-1 group"> <div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? ( {onCreateCalendar ? (
<button <button
@@ -299,6 +474,8 @@ export function CalendarSidebarPanel({
</div> </div>
</div> </div>
))} ))}
</>
)}
{renderCalendarMenu()} {renderCalendarMenu()}
</div> </div>
+25 -1
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button"; 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 { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store"; import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -26,6 +26,8 @@ interface CalendarToolbarProps {
selectedCalendarIds?: string[]; selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void; onToggleVisibility?: (id: string) => void;
enableCalendarTasks?: boolean; enableCalendarTasks?: boolean;
/** Show a burger button at the start that opens the (overlay) sidebar. */
onMenuClick?: () => void;
} }
export function CalendarToolbar({ export function CalendarToolbar({
@@ -45,6 +47,7 @@ export function CalendarToolbar({
selectedCalendarIds, selectedCalendarIds,
onToggleVisibility, onToggleVisibility,
enableCalendarTasks, enableCalendarTasks,
onMenuClick,
}: CalendarToolbarProps) { }: CalendarToolbarProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const formatter = useFormatter(); const formatter = useFormatter();
@@ -115,11 +118,32 @@ export function CalendarToolbar({
return ( return (
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}> <div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
{/* Burger menu (rendered in pages that use a narrow overlay sidebar) */}
{onMenuClick && !isMobile && (
<Button
variant="ghost"
size="icon"
onClick={onMenuClick}
className="h-8 w-8 -ml-1 mr-1"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</Button>
)}
{/* ── MOBILE TOOLBAR ── */} {/* ── MOBILE TOOLBAR ── */}
{isMobile && ( {isMobile && (
<div className="flex flex-col gap-1 px-2 py-2"> <div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */} {/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</button>
)}
{onNavigateBack && ( {onNavigateBack && (
<button <button
onClick={onNavigateBack} onClick={onNavigateBack}
+22 -5
View File
@@ -6,6 +6,7 @@ import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { normalizeContactPhotoUri } from "@/stores/contact-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
@@ -51,6 +52,8 @@ interface ContactFormProps {
addressBooks?: AddressBook[]; addressBooks?: AddressBook[];
allKeywords?: string[]; allKeywords?: string[];
defaultAddressBookId?: string; defaultAddressBookId?: string;
/** Prefills the create form (ignored when `contact` is set). */
prefill?: { email?: string; name?: string };
onSave: (data: Partial<ContactCard>) => Promise<void>; onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => void; onCancel: () => void;
} }
@@ -144,10 +147,22 @@ function Select({ value, onChange, children, className }: {
); );
} }
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) { export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form"); const t = useTranslations("contacts.form");
const isEditing = !!contact; const isEditing = !!contact;
// Split a free-form display name into given/surname for prefill.
const prefillGivenName = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts[0] || "";
})();
const prefillSurname = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts.slice(1).join(" ");
})();
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases. // Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const findComponent = (...kinds: string[]) => const findComponent = (...kinds: string[]) =>
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || ""; contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
@@ -214,9 +229,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
} }
const [prefix, setPrefix] = useState(findComponent("title", "prefix")); const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
const [givenName, setGivenName] = useState(findComponent("given")); const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName);
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle")); const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
const [surname, setSurname] = useState(findComponent("surname")); const [surname, setSurname] = useState(findComponent("surname") || prefillSurname);
const [suffix, setSuffix] = useState(findComponent("generation", "suffix")); const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
const [nickname, setNickname] = useState( const [nickname, setNickname] = useState(
@@ -230,7 +245,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "", context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
})); }));
} }
return [{ address: "", context: "" }]; return [{ address: prefill?.email || "", context: "" }];
}); });
const [phones, setPhones] = useState<PhoneEntry[]>(() => { const [phones, setPhones] = useState<PhoneEntry[]>(() => {
@@ -341,7 +356,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const initialPhotoEntry = useMemo(() => { const initialPhotoEntry = useMemo(() => {
if (!contact?.media) return null; if (!contact?.media) return null;
for (const [key, m] of Object.entries(contact.media)) { for (const [key, m] of Object.entries(contact.media)) {
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType }; if (m.kind === "photo" && m.uri) {
return { key, uri: normalizeContactPhotoUri(m.uri, m.mediaType), mediaType: m.mediaType };
}
} }
return null; return null;
}, [contact]); }, [contact]);
+14 -1
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react"; import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item"; import { ContactListItem } from "./contact-list-item";
@@ -112,6 +112,8 @@ interface ContactListProps {
onEditContact: (id: string) => void; onEditContact: (id: string) => void;
onDeleteContact: (contact: ContactCard) => void; onDeleteContact: (contact: ContactCard) => void;
onAddContactToGroup: (id: string) => void; onAddContactToGroup: (id: string) => void;
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
onMenuClick?: () => void;
} }
export function ContactList({ export function ContactList({
@@ -133,6 +135,7 @@ export function ContactList({
onEditContact, onEditContact,
onDeleteContact, onDeleteContact,
onAddContactToGroup, onAddContactToGroup,
onMenuClick,
}: ContactListProps) { }: ContactListProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const locale = useLocale(); const locale = useLocale();
@@ -267,6 +270,16 @@ export function ContactList({
<div className="border-b border-border bg-background"> <div className="border-b border-border bg-background">
<div className="px-3 py-3"> <div className="px-3 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{onMenuClick && (
<button
type="button"
onClick={onMenuClick}
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("open_categories")}
>
<Menu className="w-4 h-4" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
+149 -5
View File
@@ -2,7 +2,7 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react"; import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
@@ -10,6 +10,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store"; import { getContactDisplayName } from "@/stores/contact-store";
import { useAccountStore } from "@/stores/account-store";
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
@@ -32,6 +33,36 @@ interface ContactsSidebarProps {
onDeleteAddressBook?: (addressBook: AddressBook) => void; onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void; onRenameKeyword?: (keyword: string) => void;
className?: string; className?: string;
/**
* Pro shell: render one collapsible section per connected local account
* (active first), each with "My Address Books" / "Shared from X"
* subsections. Mirrors the calendar sidebar's Pro layout.
*/
multiAccountMode?: boolean;
}
type AddressBookAccountSplit = {
owned: AddressBook[];
sharedGroups: { label: string; books: AddressBook[] }[];
};
function splitAccountBooks(list: AddressBook[]): AddressBookAccountSplit {
const owned: AddressBook[] = [];
const sharedBuckets = new Map<string, { label: string; books: AddressBook[] }>();
for (const book of list) {
if (book.isShared) {
const key = book.accountId || book.accountName || book.id;
const bucket = sharedBuckets.get(key);
if (bucket) {
bucket.books.push(book);
} else {
sharedBuckets.set(key, { label: book.accountName || key, books: [book] });
}
} else {
owned.push(book);
}
}
return { owned, sharedGroups: Array.from(sharedBuckets.values()) };
} }
const COLLAPSED_KEY = "contacts-sidebar-collapsed"; const COLLAPSED_KEY = "contacts-sidebar-collapsed";
@@ -70,6 +101,7 @@ export function ContactsSidebar({
onDeleteAddressBook, onDeleteAddressBook,
onRenameKeyword, onRenameKeyword,
className, className,
multiAccountMode,
}: ContactsSidebarProps) { }: ContactsSidebarProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const router = useRouter(); const router = useRouter();
@@ -136,6 +168,47 @@ export function ContactsSidebar({
return Array.from(map.values()); return Array.from(map.values());
}, [addressBooks]); }, [addressBooks]);
// Pro / multi-account grouping: each local account is its own collapsible
// section with owned / shared sub-buckets.
const localAccounts = useAccountStore((s) => s.accounts);
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
const localAccountGroups = useMemo(() => {
if (!multiAccountMode) return [];
const byAccount = new Map<string, AddressBook[]>();
for (const book of addressBooks) {
const key = book.localAccountId || '__other__';
const list = byAccount.get(key) ?? [];
list.push(book);
byAccount.set(key, list);
}
const ordered: { key: string; label: string; split: AddressBookAccountSplit }[] = [];
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: splitAccountBooks(byAccount.get(activeLocalAccountId)!),
});
byAccount.delete(activeLocalAccountId);
}
for (const acct of localAccounts) {
if (!byAccount.has(acct.id)) continue;
ordered.push({
key: acct.id,
label: acct.label || acct.email || acct.username,
split: splitAccountBooks(byAccount.get(acct.id)!),
});
byAccount.delete(acct.id);
}
for (const [key, list] of byAccount.entries()) {
const fallback = key === '__other__'
? t('address_books.title')
: list[0]?.accountName || key;
ordered.push({ key, label: fallback, split: splitAccountBooks(list) });
}
return ordered;
}, [multiAccountMode, addressBooks, localAccounts, activeLocalAccountId, t]);
// Count contacts per address book // Count contacts per address book
const contactCountByBook = useMemo(() => { const contactCountByBook = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
@@ -257,8 +330,77 @@ export function ContactsSidebar({
</span> </span>
</button> </button>
{/* My Address Books */} {/* Address Books: per-account groups in multi-account Pro mode, else the
{personalBooks.length > 0 && ( classic "My Address Books" section. */}
{multiAccountMode && localAccountGroups.length > 0 ? (
localAccountGroups.map((group) => {
const sectionKey = `account-${group.key}`;
const expanded = !collapsed[sectionKey];
const { owned, sharedGroups } = group.split;
return (
<div key={group.key} className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(sectionKey)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
>
{expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
<User className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-semibold text-foreground/90 uppercase tracking-wider truncate">
{group.label}
</span>
</button>
</div>
{expanded && (
<div className="pl-2">
{owned.length > 0 && (
<div className="mt-1">
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
{t("address_books.title")}
</div>
{owned.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
)}
{sharedGroups.map((sg) => (
<div key={`${group.key}-shared-${sg.label}`} className="mt-1">
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{sg.label}
</div>
{sg.books.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
))}
</div>
)}
</div>
);
})
) : (
personalBooks.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<div className="flex items-center px-3 py-1 group"> <div className="flex items-center px-3 py-1 group">
<button <button
@@ -298,6 +440,7 @@ export function ContactsSidebar({
/> />
))} ))}
</div> </div>
)
)} )}
{/* Groups section */} {/* Groups section */}
@@ -398,8 +541,9 @@ export function ContactsSidebar({
)} )}
</div> </div>
{/* Shared accounts with address books */} {/* Shared accounts with address books - only when not already split
{sharedBookGroups.map((group) => ( into per-account groups above (multi-account Pro mode). */}
{!multiAccountMode && sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2"> <div key={group.accountId} className="mt-2">
<div className="flex items-center px-3 py-1 group"> <div className="flex items-center px-3 py-1 group">
<button <button
@@ -389,7 +389,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionError(null); setActionError(null);
try { try {
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST // JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback in // is lost. Fetch raw ICS to extract METHOD as a reliable fallback - in
// parallel with parsing to save a roundtrip. // parallel with parsing to save a roundtrip.
const [events, rawText] = await Promise.all([ const [events, rawText] = await Promise.all([
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId), client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
@@ -420,7 +420,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setState('parsed'); setState('parsed');
// Hydrate the calendar store with the matching event in the background // Hydrate the calendar store with the matching event in the background -
// only needed for the "already in calendar" pill, must not block the banner. // only needed for the "already in calendar" pill, must not block the banner.
// Filter by UID server-side; the previous unfiltered query fetched up to // Filter by UID server-side; the previous unfiltered query fetched up to
// 1000 events plus multiple /get batches just to find one match. // 1000 events plus multiple /get batches just to find one match.
+151 -9
View File
@@ -14,6 +14,7 @@ import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store"; import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
@@ -34,6 +35,10 @@ import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { resolveReplyFrom } from "@/lib/reply-identity"; import { resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
} from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor"; import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
@@ -75,6 +80,11 @@ interface EmailComposerProps {
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
envelopeMailFrom?: string; envelopeMailFrom?: string;
/** Local account ID owning the selected identity. Set when the user
* picked an identity from a non-active account in the Pro multi-
* account dropdown; parents should send through that account's
* client instead of the currently-active one. */
localAccountId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
@@ -195,8 +205,18 @@ export function EmailComposer({
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const identities = useIdentityStore((s) => s.identities); const activeIdentities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null; // Pro shell: surface identities from every connected account, grouped
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
// the active account's identities only.
const multiAccountIdentities = useProMultiAccountIdentities();
const identities = multiAccountIdentities.enabled
? multiAccountIdentities.allIdentities
: activeIdentities;
const identityGroups = multiAccountIdentities.enabled
? multiAccountIdentities.groups
: [];
const primaryIdentity = activeIdentities[0] ?? null;
// The signature identity used when embedding the signature into the initial // The signature identity used when embedding the signature into the initial
// body for "above quote" mode. Mirrors the signatureIdentity derivation // body for "above quote" mode. Mirrors the signatureIdentity derivation
@@ -300,7 +320,8 @@ export function EmailComposer({
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const wrap = replyTo.quoteWrapInBlockquote !== false; const wrap = replyTo.quoteWrapInBlockquote !== false;
const originalHtml = replyTo.htmlBody const originalHtml = replyTo.htmlBody
?? (replyTo.body ? rewriteCidImagesForEditor(replyTo.htmlBody)
: (replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') ? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
: ''); : '');
const bodyHtml = wrap const bodyHtml = wrap
@@ -314,7 +335,10 @@ export function EmailComposer({
const quoteHeader = mode === 'forward' const quoteHeader = mode === 'forward'
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>` ? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`; : `On ${date}, ${fromStr} wrote:<br>`;
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`; // cid: image refs are rewritten so they render in the editor (browsers
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
} }
if (replyTo.body) { if (replyTo.body) {
@@ -410,6 +434,19 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity; : primaryIdentity;
// When the selected identity belongs to a non-active account (Pro
// multi-account dropdown), `currentIdentity.id` carries a "<localId>::"
// namespace and JMAP calls must be routed through that account's
// client with the un-prefixed id. `composerClient` and
// `currentIdentityRawId` are what save/send code should use.
const currentIdentityParts = currentIdentity?.id
? stripCrossAccountIdentityPrefix(currentIdentity.id)
: { localAccountId: null, rawId: undefined };
const composerClient = currentIdentityParts.localAccountId
? (useAuthStore.getState().getClientForAccount(currentIdentityParts.localAccountId) ?? client)
: client;
const currentIdentityRawId = currentIdentityParts.rawId ?? currentIdentity?.id;
// Alias identities often lack a configured signature - fall back to the primary // Alias identities often lack a configured signature - fall back to the primary
// identity's signature so replies (which auto-select a matching alias) still // identity's signature so replies (which auto-select a matching alias) still
// populate the user's signature. // populate the user's signature.
@@ -543,6 +580,76 @@ export function EmailComposer({
selectedIdentityId, selectedIdentityId,
]); ]);
// Hydrate inline images referenced by the quoted body (issue #163).
// `getInitialBody` rewrites `<img src="cid:xxx">` to placeholder src +
// data-cid; here we (1) register each inline attachment in inlineImagesRef
// so the send path re-attaches the blob with the right cid, and (2) fetch
// each blob as a data URL and swap it into the body so the editor actually
// shows the image instead of a blank placeholder.
useEffect(() => {
if (plainTextMode) return;
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
if (!composerClient || !replyTo?.attachments?.length) return;
const inlineAtts = replyTo.attachments.filter((att) =>
att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')
);
if (inlineAtts.length === 0) return;
// Seed the ref synchronously so a fast Send still attaches the right blobs
// even if the FileReader work below hasn't resolved yet.
for (const att of inlineAtts) {
if (!att.cid) continue;
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
inlineImagesRef.current.push({
cid: att.cid,
blobId: att.blobId,
type: att.type,
name: att.name || 'inline',
size: att.size,
dataUrl: '',
});
}
let cancelled = false;
(async () => {
const updates = new Map<string, string>();
for (const att of inlineAtts) {
if (!att.cid) continue;
try {
const buffer = await composerClient.fetchBlobArrayBuffer(
att.blobId,
att.name || 'inline',
att.type,
);
if (cancelled) return;
const blob = new Blob([buffer], { type: att.type });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
if (cancelled) return;
const entry = inlineImagesRef.current.find((e) => e.cid === att.cid);
if (entry) entry.dataUrl = dataUrl;
updates.set(att.cid, dataUrl);
} catch (err) {
debug.error('Failed to load inline image for compose', err);
}
}
if (cancelled || updates.size === 0) return;
setBody((prev) => replaceInlineImagePlaceholders(prev, updates));
})();
return () => {
cancelled = true;
};
// We deliberately hydrate once per composer open - subsequent replyTo
// object identity churn from parent renders shouldn't refetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [composerClient, plainTextMode, mode]);
const composerSignatureHtml = signatureIdentity?.htmlSignature const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>` ? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature : signatureIdentity?.textSignature
@@ -944,7 +1051,7 @@ export function EmailComposer({
// Auto-save draft functionality // Auto-save draft functionality
const saveDraftOnce = async (): Promise<string | null> => { const saveDraftOnce = async (): Promise<string | null> => {
if (!client) return null; if (!client || !composerClient) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
@@ -990,13 +1097,16 @@ export function EmailComposer({
try { try {
const previousDraftId = draftIdRef.current; const previousDraftId = draftIdRef.current;
const savedDraftId = await client.createDraft( // Use the JMAP client and raw identity id for the *owning* account
// - falls back to active client for single-account / same-account
// identities. See `composerClient` derivation above.
const savedDraftId = await composerClient.createDraft(
toAddresses, toAddresses,
subject || t('no_subject'), subject || t('no_subject'),
plainTextMode ? body : htmlToPlainText(body), plainTextMode ? body : htmlToPlainText(body),
ccAddresses, ccAddresses,
bccAddresses, bccAddresses,
currentIdentity?.id, currentIdentityRawId,
fromEmail, fromEmail,
previousDraftId || undefined, previousDraftId || undefined,
uploadedAttachments, uploadedAttachments,
@@ -1321,6 +1431,13 @@ export function EmailComposer({
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
// S/MIME keys are scoped to one JMAP account's identity - sending
// from a cross-account identity via S/MIME would mix accounts'
// certs/clients. Refuse upfront and tell the user to switch.
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id);
if (crossAccount.localAccountId) {
throw new Error('S/MIME sending from another accounts identity is not supported. Switch to that account first.');
}
// 1. Resolve S/MIME key // 1. Resolve S/MIME key
if (smimeSign_ && !smimeKeyRecord) { if (smimeSign_ && !smimeKeyRecord) {
throw new Error('No S/MIME key bound to this identity'); throw new Error('No S/MIME key bound to this identity');
@@ -1475,6 +1592,15 @@ export function EmailComposer({
}; };
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput); const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
// Strip the cross-account namespace from the identity id before
// handing it to the parent - the JMAP server only knows the raw
// id. The owning local account travels alongside so the parent
// can route the send through the right client.
const rawIdentityId = outgoing.identityId || currentIdentity?.id;
const { localAccountId: identityLocalAccountId, rawId } = rawIdentityId
? stripCrossAccountIdentityPrefix(rawIdentityId)
: { localAccountId: null, rawId: undefined };
await onSend?.({ await onSend?.({
to: outgoing.to, to: outgoing.to,
cc: outgoing.cc, cc: outgoing.cc,
@@ -1485,8 +1611,9 @@ export function EmailComposer({
draftId: finalDraftId || undefined, draftId: finalDraftId || undefined,
fromEmail, fromEmail,
fromName, fromName,
identityId: outgoing.identityId || currentIdentity?.id, identityId: rawId,
envelopeMailFrom, envelopeMailFrom,
localAccountId: identityLocalAccountId ?? undefined,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo, inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references, references: threadingHeaders?.references,
@@ -1716,7 +1843,22 @@ export function EmailComposer({
onChange={(e) => setSelectedIdentityId(e.target.value)} onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate" className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
> >
{identities.map((identity) => { {identityGroups.length > 0
? identityGroups.map((group) => (
<optgroup key={group.localAccountId} label={group.accountLabel}>
{group.identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</optgroup>
))
: identities.map((identity) => {
const displayEmail = subAddressTag const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter) ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email; : identity.email;
+32 -2
View File
@@ -66,6 +66,7 @@ import {
CalendarClock, CalendarClock,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
@@ -79,6 +80,7 @@ import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner"; import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { useTour } from "@/components/tour/tour-provider"; import { useTour } from "@/components/tour/tour-provider";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover"; import { RecipientPopover } from "./recipient-popover";
@@ -954,6 +956,7 @@ export function EmailViewer({
}, [client, t, tComposer]); }, [client, t, tComposer]);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour(); const { startTour } = useTour();
const isEmbedded = useIsEmbedded();
const [showFullHeaders, setShowFullHeaders] = useState(false); const [showFullHeaders, setShowFullHeaders] = useState(false);
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false); const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false); const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
@@ -1170,9 +1173,34 @@ export function EmailViewer({
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null); const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
const contacts = useContactStore((s) => s.contacts); const contacts = useContactStore((s) => s.contacts);
const { isMobile: isMobileDevice } = useDeviceDetection(); const { isMobile: isMobileDevice } = useDeviceDetection();
const router = useRouter();
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => { const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
if (isMobileDevice) return; // no sidebar on mobile if (isMobileDevice) {
// No room for a sidebar on mobile - send the user to the contacts page
// with params describing what to show. The `from=email` flag turns the
// page's mobile back button into a router.back() that returns here.
const allRecipients = [
...(email?.from || []),
...(email?.to || []),
...(email?.cc || []),
...(email?.bcc || []),
...(email?.replyTo || []),
];
const recipientName = allRecipients.find(
(r) => r.email.toLowerCase() === recipientEmail.toLowerCase()
)?.name;
const params = new URLSearchParams();
if (contact) {
params.set('contactId', contact.id);
} else {
params.set('addEmail', recipientEmail);
if (recipientName) params.set('addName', recipientName);
}
params.set('from', 'email');
router.push(`/contacts?${params.toString()}`);
return;
}
setContactSidebarEmail(recipientEmail); setContactSidebarEmail(recipientEmail);
}; };
@@ -2903,7 +2931,7 @@ export function EmailViewer({
// window between selectedEmail changing and isLoading flipping true, so the // window between selectedEmail changing and isLoading flipping true, so the
// quick reply / body don't flicker through a partial render. // quick reply / body don't flicker through a partial render.
// An empty bodyValues with no referenced parts means the email has no body // An empty bodyValues with no referenced parts means the email has no body
// (e.g. calendar-only invites) not "still loading". // (e.g. calendar-only invites) - not "still loading".
const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0; const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0;
const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0)); const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0));
@@ -3260,6 +3288,7 @@ export function EmailViewer({
} }
return ( return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}> <div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
{!isEmbedded && (
<div className="text-center p-8"> <div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center"> <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" /> <Mail className="w-10 h-10 text-muted-foreground" />
@@ -3273,6 +3302,7 @@ export function EmailViewer({
</Button> </Button>
)} )}
</div> </div>
)}
</div> </div>
); );
} }
+11 -1
View File
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
width: { default: null }, width: { default: null },
cid: { cid: {
default: null, 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 } : {}), renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
}, },
}; };
+134 -7
View File
@@ -11,7 +11,9 @@ import {
AlertCircle, Star, Clock, FolderUp, AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode, FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon, Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu,
} from "lucide-react"; } from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { NewFolderDialog } from "@/components/files/new-folder-dialog"; 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 type { FolderLayout } from "@/components/files/files-settings-dialog";
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar"; import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { Avatar } from "@/components/ui/avatar";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import type { FileResource } from "@/stores/file-store"; import type { FileResource } from "@/stores/file-store";
@@ -35,6 +38,13 @@ interface ClipboardState {
sourceParentId: string | null; sourceParentId: string | null;
} }
export interface AccountFolderEntry {
accountId: string;
label: string;
email: string;
avatarColor: string;
}
interface FileBrowserProps { interface FileBrowserProps {
currentPath: string; currentPath: string;
resources: FileResource[]; resources: FileResource[];
@@ -78,6 +88,13 @@ interface FileBrowserProps {
showDetails: boolean; showDetails: boolean;
onToggleDetails: () => void; onToggleDetails: () => void;
detailResource: FileResource | null; 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"]); const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
@@ -321,6 +338,10 @@ export function FileBrowser({
onToggleDetails, onToggleDetails,
detailResource, detailResource,
clipboard, clipboard,
accountFolders,
onSelectAccount,
accountPickerMode,
accountLabel,
}: FileBrowserProps) { }: FileBrowserProps) {
const t = useTranslations("files"); const t = useTranslations("files");
const [showNewFolder, setShowNewFolder] = useState(false); const [showNewFolder, setShowNewFolder] = useState(false);
@@ -352,6 +373,13 @@ export function FileBrowser({
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256); const dragStartWidth = useRef(256);
const [dragTarget, setDragTarget] = useState<string | null>(null); const [dragTarget, setDragTarget] = useState<string | null>(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 // Sync showThumbnails and folderLayout when settings change
useEffect(() => { useEffect(() => {
@@ -442,8 +470,10 @@ export function FileBrowser({
return sorted; return sorted;
}, [resources, searchQuery, sortKey, sortDir, folderLayout]); }, [resources, searchQuery, sortKey, sortDir, folderLayout]);
// Build breadcrumb segments // Build breadcrumb segments. In Pro mode an account is mounted "between"
const breadcrumbs = currentPath === '/' // 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: '/' }]
: [ : [
{ name: t("breadcrumb_root"), path: '/' }, { name: t("breadcrumb_root"), path: '/' },
@@ -452,14 +482,24 @@ export function FileBrowser({
path: '/' + arr.slice(0, i + 1).join('/'), path: '/' + arr.slice(0, i + 1).join('/'),
})), })),
]; ];
if (accountLabel) {
breadcrumbs.splice(1, 0, { name: accountLabel, path: '', isAccount: true });
}
const handleNavigateUp = useCallback(() => { const handleNavigateUp = useCallback(() => {
if (currentPath === '/') return; if (currentPath === '/') return;
const segments = currentPath.split('/').filter(Boolean); const segments = currentPath.split('/').filter(Boolean);
segments.pop(); segments.pop();
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/'); const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
onNavigate(parentPath, null); // Pro shell: going up to root from a subfolder must land on the
}, [currentPath, onNavigate]); // 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) => { const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
if (resource.isDirectory) { if (resource.isDirectory) {
@@ -846,14 +886,27 @@ export function FileBrowser({
> >
{/* Toolbar */} {/* Toolbar */}
<div role="toolbar" aria-label={t("toolbar")} className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background"> <div role="toolbar" aria-label={t("toolbar")} className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background">
{isNarrow && folderLayout === "sidebar" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -ml-2"
onClick={() => setNarrowSidebarOpen((v) => !v)}
aria-label={t("open_folder_tree")}
>
<Menu className="w-4 h-4" />
</Button>
)}
{/* Breadcrumbs */} {/* Breadcrumbs */}
<nav aria-label={t("breadcrumb_root")} className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto"> <nav aria-label={t("breadcrumb_root")} className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto">
{breadcrumbs.map((crumb, i) => ( {breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-1 shrink-0"> <span key={`${i}:${crumb.path}`} className="flex items-center gap-1 shrink-0">
{i > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />} {i > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />}
<button <button
onClick={() => onNavigate(crumb.path)} onClick={() => crumb.isAccount
onContextMenu={(e) => handleBreadcrumbRightClick(e, crumb.path)} ? onNavigate('/', '__account_root__')
: onNavigate(crumb.path)}
onContextMenu={(e) => crumb.isAccount ? undefined : handleBreadcrumbRightClick(e, crumb.path)}
className={cn( className={cn(
"px-1.5 py-0.5 rounded hover:bg-muted transition-colors", "px-1.5 py-0.5 rounded hover:bg-muted transition-colors",
i === breadcrumbs.length - 1 i === breadcrumbs.length - 1
@@ -902,6 +955,7 @@ export function FileBrowser({
{t("paste")} ({clipboard.names.length}) {t("paste")} ({clipboard.names.length})
</Button> </Button>
)} )}
{!accountPickerMode && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -911,6 +965,7 @@ export function FileBrowser({
> >
<Search className="w-4 h-4" /> <Search className="w-4 h-4" />
</Button> </Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -920,6 +975,8 @@ export function FileBrowser({
> >
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <LayoutList className="w-4 h-4" />} {viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <LayoutList className="w-4 h-4" />}
</Button> </Button>
{!accountPickerMode && (
<>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -976,6 +1033,8 @@ export function FileBrowser({
> >
<FilePlus className="w-4 h-4" /> <FilePlus className="w-4 h-4" />
</Button> </Button>
</>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -1100,8 +1159,40 @@ export function FileBrowser({
{/* File list */} {/* File list */}
<div className="flex-1 min-h-0 flex relative"> <div className="flex-1 min-h-0 flex relative">
{/* Narrow-pane backdrop for the overlay folder tree */}
{folderLayout === "sidebar" && isNarrow && narrowSidebarOpen && (
<div
className="absolute inset-0 bg-black/50 z-40"
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{/* Folder tree sidebar (when layout is sidebar) */} {/* Folder tree sidebar (when layout is sidebar) */}
{folderLayout === "sidebar" && ( {folderLayout === "sidebar" && (
isNarrow ? (
<div
className={cn(
"absolute inset-y-0 left-0 z-50",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)}
onClick={(e) => {
// Auto-close when the user taps a folder name. Chevrons stay
// open so they can expand/collapse without dismissing.
const target = e.target as HTMLElement;
const btn = target.closest('button');
if (btn && !btn.querySelector('svg.lucide-chevron-right, svg.lucide-chevron-down')) {
setNarrowSidebarOpen(false);
}
}}
>
<FolderTreeSidebar
currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={288}
/>
</div>
) : (
<> <>
<FolderTreeSidebar <FolderTreeSidebar
currentPath={currentPath} currentPath={currentPath}
@@ -1120,6 +1211,7 @@ export function FileBrowser({
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }} onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }}
/> />
</> </>
)
)} )}
{/* Favorites & Recent sidebar (when layout is inline) */} {/* Favorites & Recent sidebar (when layout is inline) */}
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && ( {folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
@@ -1198,6 +1290,41 @@ export function FileBrowser({
<SkeletonRow /> <SkeletonRow />
</tbody> </tbody>
</table> </table>
) : accountPickerMode && accountFolders && accountFolders.length > 0 && onSelectAccount ? (
/* ======= ACCOUNT PICKER (Pro mode root) ======= */
<div className="p-4">
<div
className="grid gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(11rem, 1fr))' }}
>
{accountFolders.map((acc) => (
<button
key={`__account__:${acc.accountId}`}
onClick={() => onSelectAccount(acc.accountId)}
title={acc.email}
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-left min-w-0"
>
<Avatar
name={acc.label}
email={acc.email}
size="md"
fallbackColor={acc.avatarColor}
className="shrink-0"
/>
<div className="min-w-0 flex flex-col">
<span className="truncate text-sm font-medium">{acc.label || acc.email}</span>
{acc.label && acc.label !== acc.email && (
<span className="truncate text-xs text-muted-foreground">{acc.email}</span>
)}
</div>
</button>
))}
</div>
</div>
) : accountPickerMode ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("no_accounts")}</p>
</div>
) : resources.length === 0 && !searchQuery && currentPath === '/' ? ( ) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
<FileUploadArea <FileUploadArea
onUpload={async (files: File[]) => { onUpload={async (files: File[]) => {
+1 -1
View File
@@ -130,7 +130,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
return ( return (
<div <div
className={cn( className={cn(
"border-r border-border bg-secondary overflow-hidden shrink-0 hidden lg:flex flex-col", "border-r border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
!isResizing && "transition-[width] duration-300" !isResizing && "transition-[width] duration-300"
)} )}
style={{ width: `${width}px` }} style={{ width: `${width}px` }}
+4 -1
View File
@@ -78,7 +78,10 @@ export function FilterRuleModal({
const pathMap = new Map<string, string>(); const pathMap = new Map<string, string>();
const buildPaths = (nodes: MailboxNode[], parentPath = "") => { const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
for (const node of nodes) { for (const node of nodes) {
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name; // Sieve fileinto expects the IMAP-canonical "INBOX" for the inbox,
// not the localized JMAP display name (e.g. "Entrada" in pt-BR).
const segment = node.role === "inbox" ? "INBOX" : node.name;
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
pathMap.set(node.id, fullPath); pathMap.set(node.id, fullPath);
if (node.children.length > 0) buildPaths(node.children, fullPath); if (node.children.length > 0) buildPaths(node.children, fullPath);
} }
+1 -1
View File
@@ -47,7 +47,7 @@ interface NavigationRailProps {
activeAppId?: string | null; activeAppId?: string | null;
/** /**
* If provided, intercepts the rail's built-in route navigation. Return * If provided, intercepts the rail's built-in route navigation. Return
* `true` to prevent the underlying `<Link>` from navigating used by the * `true` to prevent the underlying `<Link>` from navigating - used by the
* Pro interface to open the route as a tab instead. The visual rail is * Pro interface to open the route as a tab instead. The visual rail is
* unchanged. * unchanged.
*/ */
+132 -5
View File
@@ -52,6 +52,7 @@ import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { AccountSwitcher } from "./account-switcher"; import { AccountSwitcher } from "./account-switcher";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useTour } from "@/components/tour/tour-provider"; import { useTour } from "@/components/tour/tour-provider";
interface SidebarProps { interface SidebarProps {
@@ -76,6 +77,20 @@ interface SidebarProps {
scheduledTotal?: number; scheduledTotal?: number;
showScheduledMailbox?: boolean; showScheduledMailbox?: boolean;
className?: string; className?: string;
/**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
* sidebar renders a per-connected-account group instead of a single
* folders section - Thunderbird-style. `accountMailboxes` provides the
* mailbox list for non-active accounts (the active account still flows
* through the `mailboxes` prop). `viewingAccountId` highlights which
* account's folder is currently selected (null = active account).
* `onAccountMailboxSelect` fires with the owning accountId when the user
* picks a folder; callers translate that into `selectAccountMailbox`.
*/
multiAccountMode?: boolean;
accountMailboxes?: Record<string, Mailbox[]>;
viewingAccountId?: string | null;
onAccountMailboxSelect?: (accountId: string | null, mailboxId: string) => void;
} }
const ROW_PX_BASE = 8; const ROW_PX_BASE = 8;
@@ -664,10 +679,14 @@ export function Sidebar({
scheduledTotal = 0, scheduledTotal = 0,
showScheduledMailbox = false, showScheduledMailbox = false,
className, className,
multiAccountMode = false,
accountMailboxes,
viewingAccountId = null,
onAccountMailboxSelect,
}: SidebarProps) { }: SidebarProps) {
const router = useRouter(); const router = useRouter();
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity } = useAuthStore(); const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [foldersExpanded, setFoldersExpanded] = useState(() => { const [foldersExpanded, setFoldersExpanded] = useState(() => {
try { try {
@@ -699,14 +718,33 @@ export function Sidebar({
return stored !== null ? new Set(JSON.parse(stored) as string[]) : new Set(); return stored !== null ? new Set(JSON.parse(stored) as string[]) : new Set();
} catch { return new Set(); } } catch { return new Set(); }
}); });
// Per-connected-account collapse state for Pro / Thunderbird-style mode.
// Stored as the set of accountIds the user has explicitly collapsed -
// anything not in the set is treated as expanded. Inverting the storage
// model lets new accounts default to expanded automatically.
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem('sidebarCollapsedAccountGroups');
if (stored !== null) return new Set(JSON.parse(stored) as string[]);
} catch { /* fall through */ }
return new Set();
});
const emailKeywords = useSettingsStore(s => s.emailKeywords); const emailKeywords = useSettingsStore(s => s.emailKeywords);
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher); const isEmbedded = useIsEmbedded();
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
// own AccountSwitcher would be a redundant second account UI in the same
// pane.
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox); const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons); const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
const tagCounts = useEmailStore(s => s.tagCounts); const tagCounts = useEmailStore(s => s.tagCounts);
const accounts = useAccountStore(s => s.accounts); const accounts = useAccountStore(s => s.accounts);
const connectedAccounts = accounts.filter(a => a.isConnected); const connectedAccounts = accounts.filter(a => a.isConnected);
const showUnified = enableUnifiedMailbox && connectedAccounts.length > 1; // Pro shell treats the unified mailbox as a core part of the multi-account
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
// 2+ account requirement still applies - with a single account the
// unified counts would just duplicate that account's inbox.
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
const { unifiedCounts } = useEmailStore(); const { unifiedCounts } = useEmailStore();
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
@@ -754,6 +792,24 @@ export function Sidebar({
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-')); const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-'));
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-')); const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
// Multi-account mode (Pro shell): render every connected account as its
// own collapsible group. The active account's tree comes from the
// `mailboxes` prop (which is the live email-store value); other accounts
// come from the per-account cache populated by useProMultiAccountMailboxes.
const useMultiAccount = multiAccountMode && connectedAccounts.length > 1;
const accountGroups = useMultiAccount
? connectedAccounts.map((account) => {
const isActive = account.id === activeAccountId;
const accountMailboxList = isActive
? mailboxes
: (accountMailboxes?.[account.id] ?? []);
const tree = buildMailboxTree(accountMailboxList).filter(
(n) => !n.id.startsWith('shared-account-')
);
return { account, isActive, tree };
})
: [];
const getUnifiedIcon = (role: UnifiedMailboxRole) => { const getUnifiedIcon = (role: UnifiedMailboxRole) => {
switch (role) { switch (role) {
case 'inbox': return Inbox; case 'inbox': return Inbox;
@@ -833,6 +889,14 @@ export function Sidebar({
return next; return next;
}); });
}; };
const toggleAccountGroup = (id: string) => {
setCollapsedAccountGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
try { localStorage.setItem('sidebarCollapsedAccountGroups', JSON.stringify(Array.from(next))); } catch { /* */ }
return next;
});
};
const openFolderSettings = () => { const openFolderSettings = () => {
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ } try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
@@ -870,7 +934,9 @@ export function Sidebar({
className className
)} )}
> >
{/* Header */} {/* Header - hidden in the Pro shell, which owns its own chrome and
would otherwise render an empty strip (no collapse, no switcher). */}
{!isEmbedded && (
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}> <div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
<Button <Button
variant="ghost" variant="ghost"
@@ -896,6 +962,7 @@ export function Sidebar({
<AccountSwitcher variant="expanded" className="flex-1" /> <AccountSwitcher variant="expanded" className="flex-1" />
)} )}
</div> </div>
)}
{!isCollapsed && <DemoBanner />} {!isCollapsed && <DemoBanner />}
{!isCollapsed && <VacationBanner />} {!isCollapsed && <VacationBanner />}
@@ -936,6 +1003,65 @@ export function Sidebar({
</div> </div>
)} )}
{useMultiAccount ? (
accountGroups.map(({ account, isActive, tree }) => {
const expanded = !collapsedAccountGroups.has(account.id);
const isViewing = isActive ? viewingAccountId === null : viewingAccountId === account.id;
return (
<div key={account.id} onContextMenu={isActive ? handleFoldersHeaderContextMenu : undefined}>
<SidebarSectionHeader
label={account.label || account.email || account.username}
expanded={expanded}
onToggle={() => toggleAccountGroup(account.id)}
onSettings={isActive ? openFolderSettings : undefined}
settingsTitle={isActive ? t('settings') : undefined}
isCollapsed={isCollapsed}
first={!showUnified && account.id === connectedAccounts[0]?.id}
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
/>
{((expanded && !isCollapsed) || isCollapsed) && (
<>
{tree.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
<>
{tree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={(mailboxId) =>
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
/>
))}
{isActive && showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</>
)}
</div>
);
})
) : (
<div onContextMenu={handleFoldersHeaderContextMenu}> <div onContextMenu={handleFoldersHeaderContextMenu}>
<SidebarSectionHeader <SidebarSectionHeader
label={t("folders")} label={t("folders")}
@@ -984,8 +1110,9 @@ export function Sidebar({
</> </>
)} )}
</div> </div>
)}
{sharedAccounts.length > 0 && ( {!useMultiAccount && sharedAccounts.length > 0 && (
<div> <div>
<SidebarSectionHeader <SidebarSectionHeader
label={t("shared")} label={t("shared")}
+2 -2
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
// Sandboxed slot mount. One iframe per (plugin, slot) created lazily after // Sandboxed slot mount. One iframe per (plugin, slot) - created lazily after
// the background instance confirms `shouldShow(context)` (if defined). The // the background instance confirms `shouldShow(context)` (if defined). The
// iframe renders the plugin's slot component using the plugin's bundle in a // iframe renders the plugin's slot component using the plugin's bundle in a
// null-origin context; its height is pushed back via postMessage and applied // null-origin context; its height is pushed back via postMessage and applied
@@ -59,7 +59,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
try { inst.destroy(); } catch { /* ignore */ } try { inst.destroy(); } catch { /* ignore */ }
instanceRef.current = null; instanceRef.current = null;
}; };
// We intentionally don't depend on extraProps here propagating prop // We intentionally don't depend on extraProps here - propagating prop
// changes happens via postMessage below to avoid iframe churn. // changes happens via postMessage below to avoid iframe churn.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [show, pluginId, slot]); }, [show, pluginId, slot]);
+1 -1
View File
@@ -18,7 +18,7 @@ interface ProComposeTabBodyProps {
/** /**
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending, * Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
* draft autosave, and discard all flow through the shared `email-store`, so * 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. * composer is just hosted in its own tab instead of in the right pane.
*/ */
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
+2 -2
View File
@@ -39,7 +39,7 @@ function buildReplyContext(email: Email): ProReplyContext {
/** /**
* Renders a single email in its own Pro tab. Fetches the email content on * 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. * it doesn't depend on what the Mail tab has selected.
*/ */
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
@@ -160,7 +160,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
if (!client || !email) return; if (!client || !email) return;
try { try {
await toggleStar(client, email.id); 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. // shape only for the mail tab; here we update our local copy too.
setEmail((prev) => prev ? { setEmail((prev) => prev ? {
...prev, ...prev,
+38
View File
@@ -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<string, Exclude<ProTabKind, 'compose' | 'email'>> = {
'/': '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;
}
@@ -12,7 +12,7 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode
useEffect(() => { useEffect(() => {
if (!embeddedMode || !isEmbedded()) return; 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. // otherwise any cross-origin frame could forge sso:trigger-logout.
if (!parentOrigin) { if (!parentOrigin) {
console.error( console.error(
+4 -2
View File
@@ -51,7 +51,7 @@ interface IntlProviderProps {
export function IntlProvider({ locale: initialLocale, children }: IntlProviderProps) { export function IntlProvider({ locale: initialLocale, children }: IntlProviderProps) {
const currentLocale = useLocaleStore((state) => state.locale); const currentLocale = useLocaleStore((state) => state.locale);
const setLocale = useLocaleStore((state) => state.setLocale); const setLocale = useLocaleStore((state) => state.setLocale);
const [activeLocale, setActiveLocale] = useState(currentLocale || initialLocale); const [activeLocale, setActiveLocale] = useState(initialLocale);
const [timeZone, setTimeZone] = useState<string>('UTC'); const [timeZone, setTimeZone] = useState<string>('UTC');
// Detect user's timezone on mount // 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(() => { useEffect(() => {
if (!currentLocale) { if (!currentLocale) {
setLocale(initialLocale); setLocale(initialLocale);
} else {
setActiveLocale(currentLocale);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
+277 -3
View File
@@ -1,23 +1,89 @@
"use client"; "use client";
import { useState, useRef, useCallback } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-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 { 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() { export function AccountSettings() {
const t = useTranslations('settings.account'); 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 { 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 account = useAccountStore((s) => activeAccountId ? s.getAccountById(activeAccountId) : undefined);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0; const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined); const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined);
const email = primaryIdentity?.email || account?.email || username; 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 ( return (
<div className="space-y-8">
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
{/* Display Name */} {/* Display Name */}
<SettingItem label={t('name_label')}> <SettingItem label={t('name_label')}>
@@ -83,5 +149,213 @@ export function AccountSettings() {
</SettingItem> </SettingItem>
)} )}
</SettingsSection> </SettingsSection>
{/* Logged-in accounts list */}
{accounts.length > 0 && (
<SettingsSection title={t('accounts.title')} description={t('accounts.description')}>
<div className="space-y-2">
{accounts.map((a, index) => (
<AccountRow
key={a.id}
account={a}
index={index}
isActive={a.id === activeAccountId}
isFirst={index === 0}
isLast={index === accounts.length - 1}
isDragOver={dragOverIndex === index}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onMoveUp={() => 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 && (
<Button
variant="outline"
size="sm"
onClick={handleAddAccount}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
{t('accounts.add')}
</Button>
)}
</div>
</SettingsSection>
)}
</div>
);
}
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 (
<div
draggable
onDragStart={(e) => 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'
)}
>
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground flex-shrink-0"
title={labels.dragHandle}
>
<GripVertical className="w-4 h-4" />
</div>
<div className="relative flex-shrink-0">
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
className="w-9 h-9 text-sm"
disableFavicon
fallbackColor={account.avatarColor}
/>
{isActive && (
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-primary-foreground" />
</div>
)}
</div>
<button
type="button"
onClick={onSwitch}
disabled={isActive}
className={cn(
'min-w-0 flex-1 text-left',
!isActive && 'cursor-pointer'
)}
title={isActive ? labels.active : labels.switchTo}
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
{account.displayName || account.label}
</span>
{account.isDefault && (
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" aria-label={labels.default} />
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{account.email || account.username}
</p>
<div className="flex items-center gap-1 mt-0.5">
{account.hasError ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className={cn(
'w-1.5 h-1.5 rounded-full',
account.isConnected ? 'bg-green-500' : 'bg-muted-foreground/40'
)} />
)}
<span className="text-[10px] text-muted-foreground truncate">
{hostnameOf(account.serverUrl)}
</span>
</div>
</button>
<div className="flex items-center gap-0.5 flex-shrink-0">
{!account.isDefault && (
<button
type="button"
onClick={onSetDefault}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-amber-500 transition-colors"
title={labels.setDefault}
aria-label={labels.setDefault}
>
<Star className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={labels.moveUp}
aria-label={labels.moveUp}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 10l4-4 4 4" />
</svg>
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={labels.moveDown}
aria-label={labels.moveDown}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 6l4 4 4-4" />
</svg>
</button>
</div>
</div>
); );
} }
-13
View File
@@ -1,13 +1,11 @@
"use client"; "use client";
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store'; import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store'; import { useAccountStore } from '@/stores/account-store';
import { useMediaQuery } from '@/hooks/use-media-query';
const MAIL_LAYOUT_PREVIEW_ROWS = [ const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false }, { 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 { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts); const accounts = useAccountStore(s => s.accounts);
const isDesktop = useMediaQuery('(min-width: 1024px)');
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
@@ -193,20 +190,10 @@ export function LayoutSettings() {
)} )}
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}> <SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<div className="flex items-center gap-3">
{proInterface && isDesktop && (
<Link
href="/pro"
className="text-sm font-medium text-primary hover:underline"
>
{t('pro_interface.open_label')}
</Link>
)}
<ToggleSwitch <ToggleSwitch
checked={proInterface} checked={proInterface}
onChange={(v) => updateSetting('proInterface', v)} onChange={(v) => updateSetting('proInterface', v)}
/> />
</div>
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
); );
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react"; import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types"; import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
@@ -230,6 +231,12 @@ export function ShareCollectionDialog({
: detectAddressBookPreset(rights as AddressBookRights); : detectAddressBookPreset(rights as AddressBookRights);
return ( return (
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5"> <li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
<Avatar
name={principal?.name}
email={principal?.email ?? undefined}
size="sm"
className="shrink-0"
/>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate"> <div className="text-sm font-medium truncate">
{principal?.name || principal?.email || principalId} {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" className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar
name={p.name}
email={p.email ?? undefined}
size="sm"
className="shrink-0"
/>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate flex items-center gap-2"> <div className="text-sm font-medium truncate flex items-center gap-2">
{p.name} {p.name}
+1 -1
View File
@@ -8,7 +8,7 @@ import { createContext, useContext } from "react";
* read this to hide their own NavigationRail and let the shell own the * read this to hide their own NavigationRail and let the shell own the
* chrome. * 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<boolean>(false); export const EmbeddedContext = createContext<boolean>(false);
+77 -11
View File
@@ -1,13 +1,47 @@
"use client"; "use client";
import { useCallback, useState, DragEvent } from "react"; 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 { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useDragDropContext } from "@/contexts/drag-drop-context";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { getMailboxPath } from "@/lib/utils"; 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 { interface UseMailboxDropOptions {
mailbox: Mailbox; mailbox: Mailbox;
onDropComplete?: () => void; onDropComplete?: () => void;
@@ -31,7 +65,7 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false); const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore(); const { client } = useAuthStore();
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext(); const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target // 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 // Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false; 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]) { if (mailbox.isShared && draggedEmails[0]) {
// Get the source mailbox's account ID from the store const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
const mailboxes = useEmailStore.getState().mailboxes;
const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId);
// Cross-account moves are not supported
if (sourceMb?.accountId !== mailbox.accountId) { if (sourceMb?.accountId !== mailbox.accountId) {
return false; return false;
} }
@@ -107,16 +141,48 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
const emailIds: string[] = JSON.parse(emailIdsJson); const emailIds: string[] = JSON.parse(emailIdsJson);
// Move in a single bulk JMAP request (store handles counter updates). // 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<string, string[]>();
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); await moveEmailsToMailbox(client, emailIds, mailbox.id);
}
// Clear selection if any selected emails were moved // Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) { if (emailIds.some(id => selectedEmailIds.has(id))) {
clearSelection(); clearSelection();
} }
// Refresh the current mailbox view (honors active search/filters) // 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); await refreshCurrentMailbox(client);
}
const mailboxPath = getMailboxPath(mailbox, mailboxes); const mailboxPath = getMailboxPath(mailbox, mailboxes);
@@ -144,7 +210,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally { } finally {
endDrag(); 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(); const valid = isValidTarget();
+2 -2
View File
@@ -42,7 +42,7 @@ export function useMediaQuery(query: string): boolean {
/** /**
* When the Pro shell renders a page inside a (possibly split) pane, that pane * When the Pro shell renders a page inside a (possibly split) pane, that pane
* publishes its measured width via `PaneSizeContext`. Inner pages should * 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. * narrow pane gets the mobile/tablet layout instead of overflowing.
* *
* Returns `null` when no pane size is published, signalling the caller to * 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 * 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 * 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 * and the store is meant to mirror the actual viewport for callers that read
* it directly (mobile navigation helpers etc.). * it directly (mobile navigation helpers etc.).
*/ */
+1 -1
View File
@@ -4,7 +4,7 @@ import { createContext, useContext } from "react";
/** /**
* Width of the pane that's hosting the current subtree, in CSS pixels. * 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. * media queries. Set by the Pro shell on each split pane via ResizeObserver.
*/ */
export const PaneSizeContext = createContext<number | null>(null); export const PaneSizeContext = createContext<number | null>(null);
+63
View File
@@ -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 };
}
+48
View File
@@ -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 };
}
+133
View File
@@ -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
* <optgroup> 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<Record<string, Identity[]>>({});
// 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<string, Identity[]> = {};
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<AccountIdentityGroup[]>(() => {
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 };
}
+39
View File
@@ -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]);
}
+89 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("plainTextToComposerBody", () => {
it("returns an empty string for empty input", () => { 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 = '<p>hi</p><img src="https://example.com/x.png">';
expect(rewriteCidImagesForEditor(html)).toBe(html);
});
it("handles empty input", () => {
expect(rewriteCidImagesForEditor("")).toBe("");
});
it("rewrites a cid: src to placeholder + data-cid", () => {
const out = rewriteCidImagesForEditor(
'<img src="cid:abc@x" alt="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(
'<img src="cid:abc" data-cid="kept">'
);
expect(out).toContain('data-cid="kept"');
expect(out).not.toContain('data-cid="abc"');
});
it("leaves non-cid images alone", () => {
const out = rewriteCidImagesForEditor(
'<img src="https://example.com/x.png"><img src="cid:y">'
);
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 = '<img src="..." data-cid="x">';
expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html);
});
it("swaps the placeholder src to the data URL for matching cids", () => {
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="abc">`;
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 = '<img src="cid:abc" data-cid="abc">';
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 =
'<img src="https://example.com/other.png" data-cid="abc">';
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 = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="missing">`;
const out = replaceInlineImagePlaceholders(
html,
new Map([["abc", "data:image/png;base64,AAAA"]])
);
expect(out).toBe(html);
});
});

Some files were not shown because too many files have changed in this diff Show More