Compare commits

...
77 Commits
Author SHA1 Message Date
Linus Rath 1e7d2d880c i18n: add missing translation keys across 16 locales 2026-05-22 00:44:00 +02:00
Linus Rath 63efd724d2 fix: trust directory version on marketplace install/update 2026-05-22 00:20:00 +02:00
Linus Rath ba4781910d feat: marketplace update flow for installed plugins/themes 2026-05-22 00:11:10 +02:00
Linus Rath 08c85a42e1 chore: update version to 1.7.0 2026-05-21 23:47:31 +02:00
Linus Rath fc5f6f43d6 feat: expose PWA, app identity, and extension directory keys in JSON config #312 2026-05-21 23:35:58 +02:00
Linus Rath 9b22ef810e fix: allow adding contacts from mail recipient popover on mobile #306 2026-05-21 23:23:45 +02:00
Linus Rath dbc0eea148 feat: group composer From dropdown by account in Pro shell 2026-05-21 23:07:02 +02:00
Linus Rath e80412b6fd feat: show contacts from all logged-in accounts in Pro shell 2026-05-21 22:32:17 +02:00
Linus Rath 2c825af689 fix: show avatars in calendar/address book sharing menu 2026-05-21 18:58:46 +02:00
Linus Rath 4cfee4f672 feat: split owned vs shared calendars per account in sidebar in Pro shell 2026-05-21 18:54:09 +02:00
Linus Rath 7076f1ded8 feat: show calendars from all logged-in accounts in Pro shell 2026-05-21 18:48:55 +02:00
Linus Rath 9fbcdf7a5f fix: hide mail sidebar header in Pro shell 2026-05-21 18:19:30 +02:00
Linus Rath 7f35d792b2 fix: load globals.css and Geist font in plugin sandbox iframe 2026-05-21 18:15:00 +02:00
Linus Rath c9cda3e203 fix: parent navigation detaching account in Pro shell file browser 2026-05-21 18:06:59 +02:00
Linus Rath 3540bf42d9 fix: use Avatar component in Pro shell file account picker 2026-05-21 18:05:03 +02:00
Linus Rath e6782e61a8 fix: sync plugin slot iframe height with reported content height 2026-05-21 17:59:24 +02:00
Linus Rath 33e655bcce feat: narrow-pane sidebars and cross-account file picker in Pro shell 2026-05-21 17:54:03 +02:00
Linus Rath 15a67a14b3 fix: parent dir navigation jumping to root in file browser 2026-05-21 17:27:49 +02:00
Linus Rath b48eef2189 feat: hide files back button in Pro shell 2026-05-21 17:25:01 +02:00
Linus Rath 280f5bc675 feat: support cross-account email moves in Pro shell 2026-05-21 17:22:31 +02:00
Linus Rath 1756f5ac1c feat: enable search in unified mailbox in pro mode 2026-05-21 17:12:06 +02:00
Linus Rath c9435f7580 feat: always show unified mailbox in Pro shell 2026-05-21 17:01:34 +02:00
Linus Rath 426d344aa8 feat: multi-account mail sidebar and client routing for Pro shell 2026-05-21 16:52:03 +02:00
Linus Rath ed90e096b5 feat: add per-account mailbox cache for Pro shell data layer 2026-05-21 16:11:26 +02:00
Linus Rath eb6e5f589e fix: hide redundant account switcher in mail sidebar inside Pro shell 2026-05-21 15:54:33 +02:00
Linus Rath eca837962c fix: hide "Back to Mail" in settings when Pro mode is on 2026-05-21 15:49:09 +02:00
Linus Rath b75bbaa517 feat: auto-redirect to Pro shell when proInterface is on 2026-05-21 15:47:05 +02:00
Linus Rath 9763ffa2a3 fix: keep proInterface per-device instead of syncing it 2026-05-21 15:39:02 +02:00
Linus Rath d854b903e0 fix: anchor unmatched URLs into main so 404 renders 2026-05-20 23:59:10 +02:00
Linus Rath 5008857880 fix: respect server-resolved locale on first visit #309 2026-05-20 23:53:44 +02:00
Linus Rath 628966d3b5 fix: split app into (main)/(sandbox) route groups so plugin iframe hydrates properly 2026-05-20 23:41:49 +02:00
Linus Rath d45c8ef511 feat: list and reorder logged-in accounts in settings #282 2026-05-20 19:22:44 +02:00
Linus Rath ba90ec1f7a feat: warn when setup JMAP URL points at a local-only host 2026-05-20 19:11:46 +02:00
Linus Rath 5023d31202 fix: defer setup wizard HTTP detection to avoid hydration mismatch 2026-05-20 19:05:11 +02:00
Linus Rath 1c44f59ba1 feat: allow setup wizard over plain HTTP with dismissable warning gate 2026-05-20 19:01:46 +02:00
Linus Rath 433a63bf1a fix: normalize malformed contact photo data URIs #307 2026-05-20 18:42:12 +02:00
Linus Rath de847b9e9f i18n: add missing translation keys across 16 locales 2026-05-20 18:30:50 +02:00
Linus Rath d530d9614b fix: serialize draft autosave with send to stop replies stalling in Drafts #303 2026-05-19 23:33:11 +02:00
Linus Rath 9a92271f6f fix: prevent mobile dual-scroll and use full width for mail content 2026-05-19 14:38:28 +02:00
Linus Rath 97ddf935a8 fix: mobile handoff flow for OAuth authentication 2026-05-19 00:45:35 +02:00
Linus Rath 973ce1e5bd feat: add mobile handoff page and JMAP authentication verification 2026-05-19 00:05:40 +02:00
Linus Rath 7003020855 fix: prevent duplication of Bulwark rules with literal braces in values 2026-05-18 23:58:21 +02:00
Linus Rath 5cdc5997af feat: pro: pane-aware responsiveness, scoped sidebar overlay, stable pane keys 2026-05-18 20:42:21 +02:00
Linus Rath c3b4707f85 feat: pro: drop top/bottom split, keep side-by-side only 2026-05-18 19:51:43 +02:00
Linus Rath 2b3094a4ef feat: pro: unify split panes under a single tab bar 2026-05-18 19:39:57 +02:00
Linus Rath ecd0467ffa fix: stop pulling node:dns into client bundle via OAuth discovery 2026-05-18 19:31:38 +02:00
Linus Rath 43ac0725ce feat: pro: drag tabs to reorder, drag to edge to split 2026-05-18 19:30:54 +02:00
Linus Rath 98879802ae feat: add Pro interface 2026-05-18 19:17:39 +02:00
Linus Rath cf9292262d feat: pluggable reply/forward quote header #295 2026-05-18 17:53:23 +02:00
Linus Rath 3d2ed71f3a feat: support multiple flexible event reminders #170 2026-05-18 17:31:44 +02:00
Linus Rath dcd2f4b079 fix: wire orphaned admin policy gates and surface OAuth scope settings 2026-05-18 16:47:20 +02:00
Linus Rath b2d24670ff fix: scope iCal subscriptions per JMAP account and fix refresh/clear 2026-05-18 16:39:02 +02:00
Linus Rath fa261fecfd fix: omit empty cc/bcc from Email/set so server does not emit bare Cc: header #301 2026-05-18 16:36:45 +02:00
Linus Rath 40f36baf94 fix: iCal subscription refresh, rollback, and URL normalization 2026-05-18 16:31:16 +02:00
Linus Rath 400703154e fix: ignore plugin-supplied target in ui.openExternalUrl to block host-frame hijack 2026-05-18 16:18:00 +02:00
Linus Rath 3ceada7b8a fix: tighten HTML sanitization at plain-text email + signature + i18n render sites 2026-05-18 16:07:34 +02:00
Linus Rath eb0643d887 fix: pin parent origin in iframe-bridge to block cross-frame postMessage 2026-05-18 16:01:29 +02:00
Linus Rath 1fc670138b fix: update bundleHash to full SHA-256 for integrity verification and migrate legacy hashes 2026-05-18 15:57:52 +02:00
Linus Rath 313a1fcce9 fix: stop persisting S/MIME passphrases in sessionStorage 2026-05-18 15:32:04 +02:00
Linus Rath 7efd8d59bf fix: escape print-window fields and re-sanitize body to block XSS 2026-05-18 13:24:52 +02:00
Linus Rath c2eb2c081b fix: gate admin routes against cross-origin CSRF 2026-05-18 13:21:01 +02:00
Linus Rath b299a0b602 fix: validate plugin/theme id in marketplace install to block path traversal 2026-05-18 13:03:49 +02:00
Linus Rath f275fbe2e4 fix: bind stalwart auth context to credential, not cookie-claimed username 2026-05-18 13:00:40 +02:00
Linus Rath f134766fd1 fix: validate OAuth discovery endpoints against SSRF 2026-05-18 12:53:43 +02:00
Linus Rath 6ebf720688 fix: block script-bearing MIME types from inline attachment preview 2026-05-18 12:47:44 +02:00
Linus Rath b1eb2b3c9b fix: correct regex for valid API post path validation 2026-05-18 12:44:54 +02:00
Linus Rath 48aa607b56 feat: lock down plugin runtime in sandbox + signing + approval 2026-05-18 12:44:23 +02:00
Linus Rath 088810bd20 feat: harden plugin sandbox and migrate in-tree plugins 2026-05-18 12:17:54 +02:00
Linus Rath e16f572252 fix: use plugin slot offer snapshots for useSyncExternalStore 2026-05-18 11:00:05 +02:00
Linus Rath 9f312aa556 feat: sandbox plugins in null-origin iframes with postMessage RPC 2026-05-18 10:50:49 +02:00
Linus Rath c5ac68e137 fix: prevent plugin config leak to non-admin users 2026-05-18 10:24:29 +02:00
Linus Rath ed6b5d5f33 fix: clear identity signature fields when emptied 2026-05-18 00:53:54 +02:00
Linus Rath 7a72903632 feat: show size cap on identity signature fields 2026-05-18 00:34:01 +02:00
Linus Rath 92127e2f00 fix: allow table-based layouts in HTML signature sanitizer 2026-05-18 00:24:08 +02:00
Linus Rath be5ff96e4d fix: toggle recipient popover when clicking name again 2026-05-17 23:45:57 +02:00
Linus Rath 1f47b7a6a9 fix: remove white halo around photo avatars 2026-05-17 23:44:13 +02:00
Linus RathandGitHub 551984ac44 Bump version from 1.6.6 to 1.6.7 2026-05-17 19:22:07 +02:00
214 changed files with 11136 additions and 2794 deletions
+76
View File
@@ -1,5 +1,81 @@
# Changelog
## 1.7.0 (2026-05-21)
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
### Breaking Changes
- **Plugins**: Plugins now run inside a null-origin iframe sandbox and talk to the host over a postMessage RPC bridge. The in-process plugin runtime is gone; the bundled in-tree plugins have been migrated. Third-party plugins built against the old in-process API need to be ported to the sandboxed runtime.
- **Plugins**: Server-managed bundles must be Ed25519-signed by the host and approved by an admin before they load. The host public key is served from `/api/plugin-signing-pubkey` and each bundle response carries the signature in the `X-Bundle-Signature` header. User-uploaded bundles still load unsigned, but managed marketplace and dev-folder bundles do not.
- **Plugins**: `bundleHash` is now a full SHA-256 over the bundle. Legacy short hashes are migrated on first load; any out-of-band tooling that pinned the old hash format needs to be updated.
### Features
- **Pro**: Tabbed shell with drag-to-reorder, drag-to-edge to split, side-by-side panes, and pane-aware responsive layout with a scoped sidebar overlay
- **Pro**: Auto-redirect to the Pro shell when Pro mode is on; `proInterface` is kept per-device instead of syncing
- **Pro**: Multi-account mail sidebar with client routing and a per-account mailbox cache
- **Pro**: Unified mailbox always visible, with full-text search
- **Pro**: Cross-account email moves
- **Pro**: Multi-account calendar sidebar split into owned vs shared per account
- **Pro**: Multi-account contacts and a cross-account file picker
- **Pro**: Composer From dropdown grouped by account
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
- **Setup**: Warn when the JMAP URL points at a local-only host
- **Account**: List and reorder logged-in accounts from settings (#282)
- **Mail**: Mobile handoff page with JMAP authentication verification for cross-device OAuth
- **Mail**: Pluggable reply/forward quote header (#295)
- **Calendar**: Support multiple flexible event reminders (#170)
- **Admin**: Expose PWA, app identity, and extension directory keys in the JSON config (#312)
- **Admin**: Surface OAuth scope settings and wire up orphaned admin policy gates
### Security
- **Plugins**: Pin parent origin in the iframe bridge to block cross-frame postMessage
- **Plugins**: Ignore plugin-supplied `target` in `ui.openExternalUrl` to block host-frame hijack
- **Plugins**: Validate plugin/theme id in marketplace install to block path traversal
- **Plugins**: Prevent plugin config from leaking to non-admin users
- **Admin**: Gate admin routes against cross-origin CSRF
- **Auth**: Bind Stalwart auth context to the credential, not the cookie-claimed username
- **Auth**: Validate OAuth discovery endpoints against SSRF
- **Mail**: Tighten HTML sanitization at plain-text email, signature, and i18n render sites
- **Mail**: Block script-bearing MIME types from inline attachment preview
- **Mail**: Escape print-window fields and re-sanitize body to block XSS
- **S/MIME**: Stop persisting passphrases in `sessionStorage`
- **API**: Correct regex for valid API POST path validation
### Fixes
- **Mail**: Serialize draft autosave with send to stop replies stalling in Drafts (#303)
- **Mail**: Omit empty cc/bcc from `Email/set` so the server does not emit a bare `Cc:` header (#301)
- **Mobile**: Allow adding contacts from the mail recipient popover (#306)
- **Mobile**: Prevent dual-scroll and use full width for mail content
- **Mobile**: OAuth handoff flow
- **Calendar**: Scope iCal subscriptions per JMAP account; fix refresh and clear
- **Calendar**: iCal subscription refresh, rollback, and URL normalization
- **Calendar**: Show avatars in the calendar/address book sharing menu
- **Contacts**: Normalize malformed contact photo data URIs (#307)
- **Identity**: Clear identity signature fields when emptied
- **Identity**: Show size cap on identity signature fields
- **Identity**: Allow table-based layouts in the HTML signature sanitizer
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
- **Plugins**: Sync plugin slot iframe height with reported content height
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
- **Routing**: Respect server-resolved locale on first visit (#309)
- **Routing**: Split app into `(main)`/`(sandbox)` route groups so the plugin iframe hydrates properly
- **Files**: Stop parent directory navigation from jumping to root
- **Build**: Stop pulling `node:dns` into the client bundle via OAuth discovery
- **UI**: Toggle recipient popover when clicking the name again
- **UI**: Remove white halo around photo avatars
### i18n
- Add missing translation keys across 16 locales
## 1.6.7 (2026-05-17)
### Features
+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)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.6.7-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
+1 -1
View File
@@ -1 +1 @@
1.6.6
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();
}
@@ -78,7 +78,69 @@ function OAuthCallbackInner() {
setError("token_exchange_failed");
});
} else if (state) {
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
// Server-side SSO flow - state was stored in encrypted httpOnly cookie.
// Branch on mobile handoff first: the login page left a marker in
// sessionStorage if it kicked this OAuth dance off for the mobile app.
let mobileRedirectUri: string | null = null;
let mobileState: string | null = null;
try {
mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri");
mobileState = sessionStorage.getItem("mobile_state");
} catch { /* sessionStorage may be unavailable */ }
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
// Drive /api/auth/sso/complete directly so we can read the tokens
// out of the response - loginWithServerSso would consume them and
// wire up the webmail auth store, which isn't useful here. The
// server's mobile-flow branch (keyed on the pending cookie) skips
// the refresh-token cookie write for the same reason.
(async () => {
try {
const res = await fetch("/api/auth/sso/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, state }),
});
if (!res.ok) {
setError("token_exchange_failed");
return;
}
const data = await res.json();
const serverUrl = data.server_url as string | undefined;
const accessToken = data.access_token as string | undefined;
const tokenEndpoint = data.token_endpoint as string | undefined;
const clientId = data.client_id as string | undefined;
if (!serverUrl || !accessToken || !tokenEndpoint || !clientId) {
setError("token_exchange_failed");
return;
}
const fragment = new URLSearchParams({
flow: "oauth",
server_url: serverUrl,
access_token: accessToken,
token_endpoint: tokenEndpoint,
client_id: clientId,
state: mobileState ?? "",
});
if (typeof data.refresh_token === "string") {
fragment.set("refresh_token", data.refresh_token);
}
if (typeof data.expires_in === "number") {
fragment.set("expires_in", String(data.expires_in));
}
try {
sessionStorage.removeItem("mobile_redirect_uri");
sessionStorage.removeItem("mobile_state");
} catch { /* ignore */ }
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
} catch {
setError("token_exchange_failed");
}
})();
return;
}
const ssoPrefix = getPathPrefix(params.locale as string);
loginWithServerSso(code, state)
.then((success) => {
@@ -17,7 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { toast } from "@/stores/toast-store";
import { useIsMobile } from "@/hooks/use-media-query";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
@@ -45,6 +45,8 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils";
@@ -75,6 +77,13 @@ export default function CalendarPage() {
const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action");
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
// collapses into a burger-toggled overlay instead of taking inline space.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -255,12 +264,17 @@ export default function CalendarPage() {
return subscribeToPendingWebcal(openPendingWebcal);
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
// Single-account fetch path. The Pro shell aggregates calendars from
// every connected account via [[useProMultiAccountCalendars]] below, so
// skip this fetch there to avoid clobbering the merged list with the
// active client's calendars only.
useEffect(() => {
if (isEmbedded) return;
if (client && !hasFetched.current) {
hasFetched.current = true;
fetchCalendars(client);
}
}, [client, fetchCalendars]);
}, [client, fetchCalendars, isEmbedded]);
// Auto-refresh iCal subscriptions
useEffect(() => {
@@ -329,10 +343,21 @@ export default function CalendarPage() {
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
useEffect(() => {
if (isEmbedded) return;
if (client && calendars.length > 0 && dateRange) {
fetchEvents(client, dateRange.start, dateRange.end);
}
}, [client, calendars.length, dateRange, fetchEvents]);
}, [client, calendars.length, dateRange, fetchEvents, isEmbedded]);
// Pro shell only: aggregate calendars and events from every connected
// account so the sidebar lists them all (and the views render their
// events together). The hook is a no-op outside the embedded shell.
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars(
isEmbedded ? dateRange?.start ?? null : null,
isEmbedded ? dateRange?.end ?? null : null,
);
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
const navigatePrev = useCallback(() => {
let next: Date;
@@ -404,6 +429,8 @@ export default function CalendarPage() {
setMobileReturnToMonth(true);
setViewMode("day");
}
// Close the narrow-pane sidebar overlay after the user picks a date.
setNarrowSidebarOpen(false);
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => {
@@ -554,12 +581,15 @@ export default function CalendarPage() {
}, [events, client]);
const refetchCurrentRange = useCallback(async () => {
if (!client) return;
if (!client || !activeAccountId) return;
const { dateRange: currentRange } = useCalendarStore.getState();
if (currentRange) {
await fetchEvents(client, currentRange.start, currentRange.end);
if (!currentRange) return;
if (multiAccountEnabled && accountClients.length > 0) {
await fetchAllAccountsEventsFn(accountClients, activeAccountId, currentRange.start, currentRange.end);
return;
}
}, [client, fetchEvents]);
await fetchEvents(client, currentRange.start, currentRange.end);
}, [client, fetchEvents, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsEventsFn]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh calendar data via JMAP instead of reloading the page.
@@ -567,8 +597,11 @@ export default function CalendarPage() {
enabled: isAuthenticated && !!client,
onRefresh: async () => {
if (!client) return;
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
: fetchCalendars(client);
await Promise.all([
fetchCalendars(client),
calendarRefresh,
refetchCurrentRange(),
refreshAllSubscriptions(client),
]);
@@ -1217,11 +1250,11 @@ export default function CalendarPage() {
};
return (
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail */}
{!isMobile && (
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -1240,15 +1273,31 @@ export default function CalendarPage() {
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Sidebar - full height */}
{!isMobile && !inlineApp && (
{/* Narrow-pane backdrop: dim and close overlay sidebar */}
{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
className={cn(
"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
selectedDate={selectedDate}
@@ -1309,17 +1358,20 @@ export default function CalendarPage() {
onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/>
</div>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
}}
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/>
{!isNarrow && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
}}
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/>
)}
</>
)}
@@ -1341,6 +1393,7 @@ export default function CalendarPage() {
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/>
<div
@@ -1413,7 +1466,7 @@ export default function CalendarPage() {
)}
{/* Mobile Bottom Navigation */}
{isMobile && (
{isMobile && !isEmbedded && (
<div className="shrink-0">
<NavigationRail
orientation="horizontal"
@@ -2,7 +2,9 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { ArrowLeft, Users } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { ArrowLeft, Users, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -19,14 +21,17 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import { cn, generateUUID } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
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 type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -43,6 +48,7 @@ type View =
export default function ContactsPage() {
const t = useTranslations("contacts");
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -89,11 +95,26 @@ export default function ContactsPage() {
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
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 [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
const router = useRouter();
const searchParams = useSearchParams();
// One-shot intent flag: only consume the URL params on the first render that
// has them. After applying, we strip the query so a later refresh or
// re-mount doesn't re-trigger the navigation.
const intentAppliedRef = useRef(false);
// Narrow pane (Pro split or small window): the categories sidebar collapses
// into a burger-toggled overlay.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -130,12 +151,41 @@ export default function ContactsPage() {
}
}, [initialCheckDone, isAuthenticated, authLoading]);
// Pro shell only: aggregate contacts and address books from every
// connected account so the sidebar lists them all. The hook is a no-op
// outside the embedded shell.
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts();
useEffect(() => {
if (isEmbedded) return;
if (client && supportsSync && !hasFetched.current) {
hasFetched.current = true;
fetchContacts(client);
}
}, [client, supportsSync, fetchContacts]);
}, [client, supportsSync, fetchContacts, isEmbedded]);
// Consume one-shot URL params (set by the mobile recipient popover when no
// sidebar is available) and strip them so a refresh doesn't replay the
// intent. `from=email` flips the mobile back button to `router.back()`.
useEffect(() => {
if (intentAppliedRef.current) return;
const contactId = searchParams.get('contactId');
const addEmail = searchParams.get('addEmail');
const addName = searchParams.get('addName');
const from = searchParams.get('from');
if (!contactId && !addEmail && !from) return;
intentAppliedRef.current = true;
if (from === 'email') setReturnToEmail(true);
if (contactId) {
setSelectedContact(contactId);
setView('detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
setSelectedContact(null);
setView('create');
}
router.replace('/contacts');
}, [searchParams, router, setSelectedContact]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page.
@@ -143,6 +193,17 @@ export default function ContactsPage() {
enabled: isAuthenticated && !!client && supportsSync,
onRefresh: async () => {
if (!client) return;
if (multiAccountEnabled && accountClients.length > 0) {
const activeId = useAuthStore.getState().activeAccountId;
if (activeId) {
const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState();
await Promise.all([
fetchAllAccountsAddressBooks(accountClients, activeId),
fetchAllAccountsContacts(accountClients, activeId),
]);
return;
}
}
await fetchContacts(client);
},
});
@@ -194,6 +255,7 @@ export default function ContactsPage() {
} else {
setSelectedGroupId(null);
}
setNarrowSidebarOpen(false);
}, [clearSelection]);
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
@@ -336,8 +398,14 @@ export default function ContactsPage() {
toast.success(t("toast.created"));
}
setDefaultBookIdForCreate(undefined);
setCreatePrefill(undefined);
if (returnToEmail) {
setReturnToEmail(false);
router.back();
return;
}
setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]);
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
if (!selectedContact) return;
@@ -354,6 +422,14 @@ export default function ContactsPage() {
const handleCancel = () => {
setDefaultBookIdForCreate(undefined);
// Came from email → cancel returns to the email instead of the contact list.
if (returnToEmail && view === "create") {
setCreatePrefill(undefined);
setReturnToEmail(false);
router.back();
return;
}
if (view === "create") setCreatePrefill(undefined);
if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list");
} else if (view === "bulk-add-to-group") {
@@ -525,7 +601,7 @@ export default function ContactsPage() {
const renderRightPanel = () => {
switch (view) {
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":
if (!selectedContact) return null;
@@ -641,20 +717,38 @@ export default function ContactsPage() {
}
};
if (!contactsEnabled) {
return (
<div className="flex h-dvh items-center justify-center bg-background p-6">
<div className="max-w-lg text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
</div>
</div>
);
}
const showListPanel = !isMobile || view === "list";
const showRightPanel = !isMobile || view !== "list";
const mobileBackToList = () => {
if (returnToEmail) {
setReturnToEmail(false);
setCreatePrefill(undefined);
router.back();
return;
}
setView("list");
clearSelection();
};
return (
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only */}
{!isMobile && (
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -673,18 +767,33 @@ export default function ContactsPage() {
{inlineApp && (
<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 && (
<>
{/* Panel 1: Categories sidebar */}
{!isMobile && (
{/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
{(!isMobile || isNarrow) && (
<>
<div
className={cn(
"border-r border-border flex flex-col flex-shrink-0",
!isSidebarResizing && "transition-[width] duration-300"
"border-r border-border flex flex-col flex-shrink-0 bg-background",
!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
groups={groups}
@@ -721,17 +830,20 @@ export default function ContactsPage() {
}
} : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/>
</div>
<ResizeHandle
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsSidebarResizing(false);
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/>
{!isNarrow && (
<ResizeHandle
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsSidebarResizing(false);
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/>
)}
</>
)}
@@ -764,6 +876,7 @@ export default function ContactsPage() {
onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/>
</div>
@@ -793,7 +906,7 @@ export default function ContactsPage() {
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_contacts")}
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button>
</div>
)}
@@ -804,7 +917,7 @@ export default function ContactsPage() {
)}
</div>
{isMobile && (
{isMobile && !isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store";
@@ -16,6 +17,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store";
@@ -32,6 +34,9 @@ export default function FilesPage() {
const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
const accounts = useAccountStore((s) => s.accounts);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
@@ -84,6 +89,7 @@ export default function FilesPage() {
} = useFileStore();
const isMobile = useIsMobile();
const isEmbedded = useIsEmbedded();
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
const hasFetched = useRef(false);
@@ -128,13 +134,18 @@ export default function FilesPage() {
}
}, [initialCheckDone, isAuthenticated, authLoading]);
// Initialize JMAP files client
// Initialize JMAP files client. In the Pro shell, all connected accounts
// are surfaced as top-level folders at the root, so we *don't* auto-attach
// to the active account - the user picks one explicitly.
useEffect(() => {
if (isAuthenticated && client && !hasFetched.current) {
hasFetched.current = true;
initClient(client);
if (!isAuthenticated || !client || hasFetched.current) return;
hasFetched.current = true;
if (isEmbedded) {
useFileStore.getState().clearClient();
} else {
initClient(client, activeAccountId);
}
}, [isAuthenticated, client, initClient]);
}, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page.
@@ -158,6 +169,17 @@ export default function FilesPage() {
}, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
// Pro shell only: the Account breadcrumb segment signals "go to this
// account's filesystem root" via a sentinel, distinguishing it from a
// Home click (which detaches the account and returns to the picker).
if (resourceId === '__account_root__') {
void navigate(null);
return;
}
if (isEmbedded && path === '/' && resourceId === undefined) {
useFileStore.getState().clearClient();
return;
}
if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || '');
@@ -165,7 +187,7 @@ export default function FilesPage() {
// Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path);
}
}, [navigate, navigateByPath]);
}, [navigate, navigateByPath, isEmbedded]);
const handleCreateFolder = useCallback(async (name: string) => {
try {
@@ -372,13 +394,45 @@ export default function FilesPage() {
setShowDetails(v => !v);
}, []);
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
// Pro shell only: all connected accounts are equal top-level entries at
// the root. The root path "/" itself is a cross-account picker - no
// account's files are shown until the user enters one.
const accountFolders = isEmbedded
? accounts
.filter((a) => a.isConnected)
.map((a) => ({
accountId: a.id,
label: a.label || a.email,
email: a.email,
avatarColor: a.avatarColor,
}))
: [];
const isAccountPicker = isEmbedded && currentFilesAccountId === null;
const currentAccountLabel = isEmbedded && currentFilesAccountId
? (accounts.find((a) => a.id === currentFilesAccountId)?.label
|| accounts.find((a) => a.id === currentFilesAccountId)?.email
|| null)
: null;
const handleSelectAccount = useCallback((accountId: string) => {
const nextClient = getClientForAccount(accountId);
if (!nextClient) return;
const store = useFileStore.getState();
store.initClient(nextClient, accountId);
// Reset supportsFiles so the existing checkSupport effect re-runs for
// the freshly-attached client and triggers the initial navigate(null).
useFileStore.setState({ supportsFiles: null });
}, [getClientForAccount]);
if (!isAuthenticated) return null;
return (
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex flex-1 min-h-0 overflow-hidden">
{!isMobile && (
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -399,7 +453,7 @@ export default function FilesPage() {
)}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
<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="flex items-center justify-between">
<Button
@@ -477,6 +531,10 @@ export default function FilesPage() {
showDetails={showDetails}
onToggleDetails={handleToggleDetails}
detailResource={detailResource}
accountFolders={accountFolders}
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
/>
</div>
)}
@@ -484,7 +542,7 @@ export default function FilesPage() {
</div>
</div>
{isMobile && (
{isMobile && !isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
@@ -6,6 +6,9 @@ import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-p
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { locales } from "@/i18n/routing";
export default async function LocaleLayout({
@@ -34,7 +37,10 @@ export default async function LocaleLayout({
<EmbeddedBridgeProvider>
<TourProvider>
<ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
{children}
<PluginDialogHost />
<PluginConsentDialog />
</ProtocolLaunchHandlerProvider>
</TourProvider>
</EmbeddedBridgeProvider>
@@ -108,12 +108,29 @@ function VersionBadge() {
);
}
// Only redirect targets matching this scheme are honored by the mobile
// handoff path. Without the check the login page becomes an open redirector
// that funnels password and token material to any caller-supplied URL.
const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://";
export default function LoginPage() {
const router = useRouter();
const t = useTranslations("login");
const params = useParams();
const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account";
// When the mobile app launches the webmail in a browser tab it tacks on
// these params. We grab them once at mount and stash them in a ref so any
// login path that completes (password or OAuth) can hand control back to
// the app instead of routing into /mail.
const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? "";
const rawMobileState = searchParams.get("mobile_state") ?? "";
const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)
? rawMobileRedirectUri
: "";
const mobileState = mobileRedirectUri ? rawMobileState : "";
const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
@@ -159,6 +176,9 @@ export default function LoginPage() {
const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
// Captured by handleSubmit when in mobile handoff mode; consumed by the
// isAuthenticated effect to build the deep-link fragment.
const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null);
useEffect(() => {
initializeTheme();
@@ -238,6 +258,19 @@ export default function LoginPage() {
useEffect(() => {
if (isAuthenticated && !isAddAccountMode) {
// Mobile handoff: the password path completes here once the auth store
// flips isAuthenticated. Hand the verified credentials back to the
// mobile app instead of pushing to /mail. handleSubmit captured the
// values needed for the fragment.
if (isMobileHandoff && mobileHandoffPayloadRef.current) {
const fragment = new URLSearchParams({
flow: "password",
...mobileHandoffPayloadRef.current,
state: mobileState,
});
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
return;
}
let redirectTo = '/';
try {
const saved = sessionStorage.getItem('redirect_after_login');
@@ -248,7 +281,7 @@ export default function LoginPage() {
} catch { /* ignore */ }
router.push(redirectTo);
}
}, [isAuthenticated, router, isAddAccountMode]);
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
useEffect(() => {
clearError();
@@ -316,6 +349,16 @@ export default function LoginPage() {
try {
const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
// In mobile-handoff mode the callback page needs to know it should
// redirect into the app rather than into /mail. Stash the params in
// sessionStorage so the same-tab callback can read them - the SSO
// pending cookie carries the authoritative copy server-side too.
if (isMobileHandoff) {
try {
sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri);
sessionStorage.setItem("mobile_state", mobileState);
} catch { /* sessionStorage unavailable */ }
}
const res = await apiFetch('/api/auth/sso/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -324,6 +367,9 @@ export default function LoginPage() {
redirect_uri: redirectUri,
locale: params.locale,
server_id: selectedServer?.id,
...(isMobileHandoff
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
: {}),
}),
});
@@ -350,7 +396,7 @@ export default function LoginPage() {
} catch {
setOauthLoading(false);
}
}, [params.locale, selectedServer?.id]);
}, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
useEffect(() => {
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
@@ -492,6 +538,15 @@ export default function LoginPage() {
const handleOAuthLogin = async () => {
if (!oauthMetadata || !effectiveOauthClientId) return;
// In mobile-handoff mode the client-side PKCE flow doesn't help us:
// tokens would land in sessionStorage on the webmail origin and the
// mobile app couldn't read them. Route through the server-side SSO
// path instead, which has the mobile-aware /api/auth/sso/complete
// branch.
if (isMobileHandoff) {
await startServerSideSso();
return;
}
setOauthLoading(true);
const verifier = generateCodeVerifier();
@@ -546,6 +601,16 @@ export default function LoginPage() {
// when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
// Capture before login() so the isAuthenticated effect can build the
// deep-link fragment with values the user actually typed (formData may
// be cleared by the auth store on success).
if (isMobileHandoff) {
mobileHandoffPayloadRef.current = {
server_url: effectiveServerUrl,
username: formData.username,
password: formData.password,
};
}
const success = await login(
effectiveServerUrl,
formData.username,
@@ -556,7 +621,15 @@ export default function LoginPage() {
if (success) {
saveUsername(formData.username);
if (isMobileHandoff) {
// The isAuthenticated effect handles the redirect; nothing else to
// do here. Don't push to / - that would race the deep link.
return;
}
router.push('/');
} else if (isMobileHandoff) {
// Stale payload should never feed into a later retry's redirect.
mobileHandoffPayloadRef.current = null;
}
};
@@ -49,6 +49,9 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIdentitySync } from "@/hooks/use-identity-sync";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProTabStore } from "@/stores/pro-tab-store";
import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes";
import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -67,6 +70,9 @@ import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import { emailToReadView } from "@/lib/plugin-projection";
import { buildQuoteHeader } from "@/lib/quote-header";
import { useLocaleStore } from "@/stores/locale-store";
import type { QuoteHeader } from "@/lib/plugin-types";
export default function Home() {
@@ -79,6 +85,9 @@ export default function Home() {
const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const [composerSessionId, setComposerSessionId] = useState(0);
// Plugin-resolved quote header for the next reply/forward composer open.
// Cleared on close so a subsequent "compose new" doesn't reuse stale state.
const [composerQuoteHeader, setComposerQuoteHeader] = useState<QuoteHeader | null>(null);
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
@@ -211,6 +220,7 @@ export default function Home() {
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
const isEmbedded = useIsEmbedded();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
const {
emails,
@@ -272,8 +282,16 @@ export default function Home() {
batchMarkAsRead,
batchMarkAsSpam,
batchUndoSpam,
accountMailboxes,
viewingAccountId,
selectAccountMailbox,
setViewingAccount,
} = useEmailStore();
// Pro shell: populate per-account mailbox cache so the sidebar can render
// every connected account Thunderbird-style.
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
@@ -623,6 +641,65 @@ export default function Home() {
document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
// When this page is rendered inside the Pro shell as the Mail tab body,
// we hoist every "show composer" intent into its own Pro tab and reset
// the in-page state so the inline composer never appears in the Mail tab.
// This makes the Pro composer behave like Thunderbird's pop-out window.
useEffect(() => {
if (!isEmbedded || !showComposer) return;
const replyTo = selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
to: selectedEmail.to,
cc: selectedEmail.cc,
bcc: selectedEmail.bcc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
} : undefined;
const effectiveMode = pendingDraft?.mode ?? composerMode;
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
let title = t('email_composer.new_message');
if (baseSubject) {
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
} else if (effectiveMode === 'forward') {
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
} else {
title = baseSubject;
}
}
useProTabStore.getState().openComposeTab({
sessionId: composerSessionId + 1,
mode: effectiveMode,
replyTo,
initialDraftText: composerDraftText,
initialData: pendingDraft,
sourceEmailId: selectedEmail?.id ?? null,
title,
});
setComposerSessionId((s) => s + 1);
setShowComposer(false);
setComposerDraftText("");
setPendingDraft(null);
setComposerQuoteHeader(null);
// We only react to the rising edge of `showComposer` here; the other
// variables read above are captured-but-stale-safe because the next
// open will fire a fresh effect with new values.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEmbedded, showComposer]);
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
@@ -807,15 +884,17 @@ export default function Home() {
// Keep unified mailbox counts in sync when the feature is enabled and more
// than one account is connected. Runs whenever the set of connected accounts
// or the primary account's mailboxes change (a proxy for "something worth
// recounting happened").
// recounting happened"). The Pro shell always renders the unified mailbox
// regardless of the user setting, so refresh when embedded too.
useEffect(() => {
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
if (!enableUnifiedMailbox && !isEmbedded) return;
if (!isAuthenticated || !client) return;
const built = buildUnifiedAccounts();
if (built.length < 2) return;
populateUnifiedAccountMailboxes(built).then((populated) => {
refreshUnifiedCounts(populated);
});
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
}, [enableUnifiedMailbox, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
@@ -1000,6 +1079,50 @@ export default function Home() {
}
};
// Build the quote header for a reply/forward open, running it through the
// emailHooks.onBuildQuoteHeader transform so plugins can replace it. Stores
// the result in composerQuoteHeader; the render site spreads it into
// EmailComposer.replyTo. Errors fall back to the composer's built-in
// header (state set to null).
const prepareComposerQuoteHeader = useCallback(async (
email: Email | null,
mode: 'reply' | 'replyAll' | 'forward',
) => {
if (!email) { setComposerQuoteHeader(null); return; }
try {
const replyTargets = (email.replyTo?.length
? email.replyTo
: email.from ?? []).filter(r => r.email).map(r => r.email!);
const newTo = mode === 'reply'
? replyTargets
: mode === 'replyAll'
? [...replyTargets, ...(email.to ?? []).filter(r => r.email).map(r => r.email!)]
: [];
const newCc = mode === 'replyAll'
? (email.cc ?? []).filter(r => r.email).map(r => r.email!)
: [];
const header = await buildQuoteHeader({
mode,
email: {
from: email.from,
to: email.to,
cc: email.cc,
subject: email.subject,
receivedAt: email.receivedAt,
},
newTo,
newCc,
locale: useLocaleStore.getState().locale,
timeFormat: useSettingsStore.getState().timeFormat,
unknownLabel: tCommon('unknown'),
});
setComposerQuoteHeader(header);
} catch (err) {
console.warn('[quote-header] plugin transform failed; using default', err);
setComposerQuoteHeader(null);
}
}, [tCommon]);
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReply.intercept({
@@ -1008,6 +1131,9 @@ export default function Home() {
mode: 'reply' as const,
});
if (!ok) return;
await prepareComposerQuoteHeader(selectedEmail, 'reply');
} else {
setComposerQuoteHeader(null);
}
setComposerDraftText(draftText || "");
setComposerMode('reply');
@@ -1075,6 +1201,9 @@ export default function Home() {
mode: 'reply-all' as const,
});
if (!ok) return;
await prepareComposerQuoteHeader(selectedEmail, 'replyAll');
} else {
setComposerQuoteHeader(null);
}
setComposerMode('replyAll');
setShowComposer(true);
@@ -1089,6 +1218,9 @@ export default function Home() {
mode: 'forward' as const,
});
if (!ok) return;
await prepareComposerQuoteHeader(selectedEmail, 'forward');
} else {
setComposerQuoteHeader(null);
}
setComposerMode('forward');
setShowComposer(true);
@@ -1297,6 +1429,35 @@ export default function Home() {
}
};
// Whenever the global active account changes, drop any non-active viewing
// override so we don't leave the email list pointed at a now-stale id.
useEffect(() => {
if (viewingAccountId && viewingAccountId === activeAccountId) {
setViewingAccount(null);
}
}, [activeAccountId, viewingAccountId, setViewingAccount]);
// Pro sidebar: user clicked a folder under a specific account group.
// accountId === null means the active account; non-null means a viewing
// override that fetches via that account's JMAP client.
const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string) => {
const viewingClient = accountId
? useAuthStore.getState().getClientForAccount(accountId) ?? client
: client;
selectAccountMailbox(accountId, mailboxId);
selectEmail(null);
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
if (viewingClient) {
await fetchEmails(viewingClient, mailboxId);
}
};
const handleMailboxSelect = async (mailboxId: string) => {
if (isUnifiedMailboxId(mailboxId)) {
const role = UNIFIED_ROLE_BY_ID[mailboxId];
@@ -1635,7 +1796,6 @@ export default function Home() {
const handleSearch = async (query: string) => {
if (!client) return;
if (isUnifiedView) return;
setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
@@ -1647,14 +1807,25 @@ export default function Home() {
const handleClearSearch = async () => {
setSearchQuery("");
clearSearchFilters();
if (client && selectedMailbox) {
if (!client) return;
// In unified view the active "mailbox" is a virtual role, so refresh via
// the unified fan-out instead of fetchEmails.
if (isUnifiedView) {
const role = useEmailStore.getState().unifiedRole;
if (role) {
const built = buildUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
await fetchUnifiedEmailsAction(populated, role);
}
return;
}
if (selectedMailbox) {
await fetchEmails(client, selectedMailbox);
}
};
const handleAdvancedSearch = async () => {
if (!client) return;
if (isUnifiedView) return;
await advancedSearch(client);
};
@@ -1664,9 +1835,9 @@ export default function Home() {
clearTimeout(advancedSearchDebounceRef.current);
}
advancedSearchDebounceRef.current = setTimeout(() => {
if (client && !isUnifiedView) advancedSearch(client);
if (client) advancedSearch(client);
}, 300);
}, [client, advancedSearch, isUnifiedView]);
}, [client, advancedSearch]);
useEffect(() => {
return () => {
@@ -1917,22 +2088,25 @@ export default function Home() {
};
// Handle reply from conversation view
const handleConversationReply = (email: Email) => {
const handleConversationReply = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
setComposerMode('reply');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
};
const handleConversationReplyAll = (email: Email) => {
const handleConversationReplyAll = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
};
const handleConversationForward = (email: Email) => {
const handleConversationForward = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'forward');
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -1960,7 +2134,7 @@ export default function Home() {
return (
<DragDropProvider>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
{isRateLimited && rateLimitSecondsLeft !== null && (
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
@@ -1976,8 +2150,8 @@ export default function Home() {
</div>
)}
<div className="flex flex-1 overflow-hidden">
{/* Desktop Navigation Rail */}
{!isMobile && !isTablet && (
{/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */}
{!isMobile && !isTablet && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
@@ -1997,25 +2171,44 @@ export default function Home() {
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
{/* Mobile/Tablet Sidebar Overlay Backdrop.
When embedded in a Pro pane the viewport is desktop-wide, so the
`lg:hidden` viewport-variant alone wouldn't gate this overlay;
scope to the pane via `absolute` so the backdrop stays inside
the pane instead of covering the whole window. */}
{(isMobile || isTablet) && sidebarOpen && !inlineApp && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed lg:hidden"
)}
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
{/* Sidebar - overlay on mobile/tablet, in-flow on desktop.
When embedded, overlay-mode is driven by pane-aware JS rather
than viewport-variants (which still see the full window). */}
<div
className={cn(
"flex-shrink-0 h-full z-50",
!isResizing && "transition-[width] duration-300",
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow
"lg:relative lg:translate-x-0",
isEmbedded
? (isMobile || isTablet
? cn(
"absolute inset-y-0 left-0 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!sidebarOpen && "-translate-x-full"
)
: "relative translate-x-0")
: cn(
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow
"lg:relative lg:translate-x-0"
),
inlineApp && "hidden"
)}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
@@ -2047,6 +2240,10 @@ export default function Home() {
}
}}
onSidebarClose={() => setSidebarOpen(false)}
multiAccountMode={isEmbedded}
accountMailboxes={accountMailboxes}
viewingAccountId={viewingAccountId}
onAccountMailboxSelect={handleAccountMailboxSelect}
/>
</ErrorBoundary>
</div>
@@ -2139,8 +2336,6 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
/>
{searchQuery && (
<button
@@ -2156,15 +2351,13 @@ export default function Home() {
<button
type="button"
onClick={toggleAdvancedSearch}
disabled={isUnifiedView}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
isUnifiedView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
title={t("advanced_search.toggle_filters")}
>
<Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -2323,6 +2516,14 @@ export default function Home() {
selectedEmailId={selectedEmail?.id}
isLoading={isLoading}
onEmailSelect={handleEmailSelect}
onEmailDoubleClick={isEmbedded ? ((email) => {
useProTabStore.getState().openEmailTab({
accountId: email.accountId ?? '',
emailId: email.id,
mailboxId: selectedMailbox,
title: email.subject?.trim() || t('email_composer.new_message'),
});
}) : undefined}
onOpenConversation={handleOpenConversation}
// Context menu handlers
onReply={(email) => {
@@ -2428,13 +2629,16 @@ export default function Home() {
shouldHideHorizontalViewerPane && "md:hidden"
)}
>
{/* Inline Composer - shown in viewer pane */}
{showComposer ? (
{/* Inline Composer - shown in viewer pane.
In Pro/embedded mode the composer is hoisted into its own
Pro tab (see the effect below), so we never render it inline. */}
{(showComposer && !isEmbedded) ? (
<ErrorBoundary
fallback={ComposerErrorFallback}
onReset={() => {
setShowComposer(false);
setComposerMode('compose');
setComposerQuoteHeader(null);
}}
>
<EmailComposer
@@ -2454,6 +2658,9 @@ export default function Home() {
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
} : undefined)}
initialDraftText={composerDraftText}
initialData={pendingDraft}
@@ -2473,6 +2680,7 @@ export default function Home() {
setComposerMode('compose');
setComposerDraftText("");
setPendingDraft(null);
setComposerQuoteHeader(null);
if (isMobile) {
setActiveView('list');
}
@@ -2586,8 +2794,8 @@ export default function Home() {
</div>
</div>
{/* Bottom Navigation - mobile and tablet */}
{(isMobile || isTablet) && activeView !== "viewer" && (
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
{(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
+402
View File
@@ -0,0 +1,402 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { PaneSizeContext } from "@/hooks/use-pane-size";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils";
import MailPage from "@/app/(main)/[locale]/page";
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
import ContactsPage from "@/app/(main)/[locale]/contacts/page";
import FilesPage from "@/app/(main)/[locale]/files/page";
import SettingsPage from "@/app/(main)/[locale]/settings/page";
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
mail: MailPage,
calendar: CalendarPage,
contacts: ContactsPage,
files: FilesPage,
settings: SettingsPage,
};
type DropTarget = 'left' | 'right' | null;
function renderTabBody(tab: ProTab): React.ReactNode {
if (tab.kind === 'compose' && tab.composeData) {
return <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
}
if (tab.kind === 'email' && tab.emailData) {
return <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
}
const Component = APP_TAB_COMPONENTS[tab.kind];
return Component ? <Component /> : null;
}
interface PaneProps {
paneId: ProPaneId;
tabs: ProTab[];
activeTabId: string | null;
loadedTabIds: string[];
onPaneFocus: (paneId: ProPaneId) => void;
isFocused: boolean;
}
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
const paneRef = useRef<HTMLDivElement | null>(null);
// Measured pane width, published to children via PaneSizeContext so that
// useDeviceDetection / useIsMobile / etc. branch on pane width - not full
// viewport - and inner pages collapse to their mobile/tablet layouts when
// the pane is narrow.
const [paneWidth, setPaneWidth] = useState<number | null>(null);
useEffect(() => {
const el = paneRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const initialRect = el.getBoundingClientRect();
if (initialRect.width > 0) setPaneWidth(initialRect.width);
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const w = entry.contentRect.width;
setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div
ref={paneRef}
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
>
<PaneSizeContext.Provider value={paneWidth}>
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</PaneSizeContext.Provider>
</div>
);
}
export default function ProHome() {
const t = useTranslations();
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
const [initialCheckDone, setInitialCheckDone] = useState(
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client
);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const {
showAppsModal,
inlineApp,
loadedApps,
handleManageApps,
handleInlineApp,
closeInlineApp,
closeAppsModal,
} = useSidebarApps();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const client = useAuthStore((s) => s.client);
const logout = useAuthStore((s) => s.logout);
const checkAuth = useAuthStore((s) => s.checkAuth);
const authLoading = useAuthStore((s) => s.isLoading);
const quota = useEmailStore((s) => s.quota);
const isPushConnected = useEmailStore((s) => s.isPushConnected);
const proInterface = useSettingsStore((s) => s.proInterface);
const tabs = useProTabStore((s) => s.tabs);
const activeMainTabId = useProTabStore((s) => s.activeTabId);
const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId);
const splitOrientation = useProTabStore((s) => s.splitOrientation);
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab);
const closeTab = useProTabStore((s) => s.closeTab);
const setActiveTab = useProTabStore((s) => s.setActiveTab);
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
const [isTabDragging, setIsTabDragging] = useState(false);
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(null);
/** Whether the split pane visually renders before (true) or after (false) main. */
const [splitLeading, setSplitLeading] = useState(false);
// Auth bootstrap (mirrors standard page)
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
useEffect(() => {
if (!initialCheckDone || typeof window === "undefined") return;
// Pro is desktop-only, and only used when the user has explicitly
// enabled it. If either precondition stops holding, hand the user back
// to the standard shell.
if (isMobile || isTablet || !proInterface) {
window.location.replace("/");
}
}, [initialCheckDone, isMobile, isTablet, proInterface]);
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
const focusedActiveTab = useMemo(() => {
const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId;
return tabs.find((t) => t.id === id) ?? null;
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
openTab(itemId);
return true;
};
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
focusedActiveTab && (
focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar'
|| focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files'
|| focusedActiveTab.kind === 'settings'
) ? focusedActiveTab.kind : null;
const isSplit = splitOrientation !== null && splitTabs.length > 0;
// ---- Body-level drop targets ----
const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
const computeDropTarget = (e: DragEvent<HTMLDivElement>): DropTarget => {
const rect = e.currentTarget.getBoundingClientRect();
const xFrac = (e.clientX - rect.left) / rect.width;
return xFrac < 0.5 ? 'left' : 'right';
};
const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => {
if (!target || !isSplit) return null;
const leftIsSplit = splitLeading;
if (target === 'left') return leftIsSplit ? 'split' : 'main';
return leftIsSplit ? 'main' : 'split';
};
const handleBodyDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const next = computeDropTarget(e);
if (next !== splitDropTarget) setSplitDropTarget(next);
};
const handleBodyDragLeave = (e: DragEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) return;
setSplitDropTarget(null);
};
const handleBodyDrop = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
const target = computeDropTarget(e);
setSplitDropTarget(null);
setIsTabDragging(false);
if (!target) return;
e.preventDefault();
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId) return;
if (isSplit) {
// Move tab to whichever pane occupies the dropped side.
const destPane = targetPaneFromDrop(target);
if (destPane) moveTabToPane(draggedId, destPane);
return;
}
// Create a new side-by-side split. `splitLeading` controls which side
// visually hosts the split pane.
moveTabToPane(draggedId, 'split', 'vertical');
setSplitLeading(target === 'left');
};
// Loading state (matches standard page exactly)
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
if (!isDesktop) return null;
// Stable keys are essential: when the split collapses, the row's child
// list goes from [splitPane, divider, mainPane] (or the leading variant)
// to [mainPane]. Without keys, React would reuse the Pane instance at
// index 0 - repurposing the *split* pane's instance into the main pane,
// which strands the main pane's ResizeObserver/paneWidth on a now-
// unmounted DOM node and reparents the mail tab body (causing remount
// + stale "still-narrow" measurements after the split is closed).
const mainPane = (
<Pane
key="pane-main"
paneId="main"
tabs={mainTabs}
activeTabId={activeMainTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'main'}
/>
);
const splitPane = isSplit ? (
<Pane
key="pane-split"
paneId="split"
tabs={splitTabs}
activeTabId={activeSplitTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'split'}
/>
) : null;
const splitDivider = isSplit ? (
<div
key="pane-divider"
aria-hidden="true"
className="flex-shrink-0 w-px bg-transparent"
style={{ borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }}
/>
) : null;
// Drop-zone overlay: a single half-body preview of where the dragged tab
// would land. The whole body is always a drop target (the entire surface
// maps to one of the four sides), so we only render the active side.
const dropZone = isTabDragging && splitDropTarget ? (
<DropZone side={splitDropTarget} />
) : null;
return (
<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-1 overflow-hidden">
{/* Leftmost Navigation Rail - identical to the standard layout */}
<div
className="w-14 bg-secondary flex flex-col flex-shrink-0"
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onShowShortcuts={() => setShowShortcutsModal(true)}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
onNavigate={handleRailNavigate}
activeItemId={railActiveItemId}
/>
</div>
{inlineApp && (
<InlineAppView
apps={loadedApps}
activeAppId={inlineApp.id}
onClose={closeInlineApp}
className="flex-1"
/>
)}
{!inlineApp && (
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
{/* Single, unified tab bar above both panes. */}
<ProTabBar
tabs={tabs}
activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab}
onClose={closeTab}
onDragStateChange={setIsTabDragging}
/>
{/* Panes container - accepts body drops for split/move. */}
<div
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{isSplit
? (splitLeading
? <>{splitPane}{splitDivider}{mainPane}</>
: <>{mainPane}{splitDivider}{splitPane}</>)
: mainPane}
{dropZone}
</div>
</div>
)}
</div>
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
{showAppsModal && (
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
)}
</div>
</EmbeddedContext.Provider>
);
}
function DropZone({ side }: { side: 'left' | 'right' }) {
return (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-0 bottom-0 w-1/2 z-10",
"bg-primary/15 ring-2 ring-primary/40 ring-inset",
side === 'left' ? "left-0" : "right-0",
)}
/>
);
}
@@ -76,6 +76,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
@@ -160,6 +161,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
'settings.account.email',
'settings.account.server',
'settings.account.storage',
'settings.account.accounts',
],
language: ['settings.appearance.language'],
notifications: ['settings.notifications'],
@@ -227,7 +229,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
// Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word.
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',
notifications: 'sound alert push badge',
appearance: 'theme dark light font size accent color animation density',
@@ -346,6 +348,7 @@ export default function SettingsPage() {
const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const isEmbedded = useIsEmbedded();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
@@ -360,6 +363,7 @@ export default function SettingsPage() {
const installedPlugins = usePluginStore((s) => s.plugins);
const installedThemes = useThemeStore((s) => s.installedThemes);
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
const proInterface = useSettingsStore((s) => s.proInterface);
// Build a per-tab haystack for fulltext search and a list of sub-results
// (individual settings) per tab. Sub-results come from translation entries
@@ -590,7 +594,7 @@ export default function SettingsPage() {
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
@@ -687,7 +691,7 @@ export default function SettingsPage() {
if (!isDesktop) {
if (mobileShowContent) {
return (
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
@@ -705,20 +709,22 @@ export default function SettingsPage() {
{renderTabContent()}
</div>
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
{!isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
return (
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
@@ -815,13 +821,15 @@ export default function SettingsPage() {
</div>
</div>
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
{!isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
@@ -829,21 +837,23 @@ export default function SettingsPage() {
// Desktop layout
return (
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex flex-1 min-h-0">
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
{!isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
)}
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
@@ -857,17 +867,19 @@ export default function SettingsPage() {
)}
style={{ width: `${settingsSidebarWidth}px` }}
>
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/')}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('back_to_mail')}
</Button>
</div>
{!proInterface && (
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/')}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('back_to_mail')}
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-3 pt-1 pb-1">
@@ -6,7 +6,7 @@ import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry {
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
// `value` omitted and `hasValue` set instead the server never echoes
// `value` omitted and `hasValue` set instead - the server never echoes
// the raw secret to the client.
value?: unknown;
source: 'admin' | 'env' | 'default';
@@ -271,8 +271,10 @@ 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 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 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 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:..." />
</Section>
<Section title="Single Sign-On">
@@ -2,9 +2,9 @@
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle } from 'lucide-react';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { isVersionSatisfied } from '@/lib/version-compare';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -21,6 +21,7 @@ interface Extension {
minAppVersion: string | null;
latestVersion: string | null;
installed: boolean;
installedVersion: string | null;
iconUrl: string | null;
bannerUrl: string | null;
author: {
@@ -104,6 +105,8 @@ export function MarketplaceTab() {
});
return;
}
const isUpdate = ext.installed;
const targetVersion = ext.latestVersion || '1.0.0';
setInstalling(ext.slug);
setMessage(null);
@@ -113,7 +116,7 @@ export function MarketplaceTab() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: ext.slug,
version: ext.latestVersion || '1.0.0',
version: targetVersion,
type: ext.type,
}),
});
@@ -122,13 +125,22 @@ export function MarketplaceTab() {
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
setMessage({
type: 'success',
text: isUpdate
? `"${ext.name}" updated to v${targetVersion}${warnings}`
: `"${ext.name}" installed successfully${warnings}`,
});
setExtensions(prev => prev.map(e =>
e.slug === ext.slug
? { ...e, installed: true, installedVersion: targetVersion }
: e,
));
} else {
setMessage({ type: 'error', text: data.error || 'Installation failed' });
setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
}
} catch {
setMessage({ type: 'error', text: 'Installation failed - network error' });
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally {
setInstalling(null);
}
@@ -270,6 +282,11 @@ function ExtensionCard({
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
const versionMismatch = !!extension.minAppVersion
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
const updateAvailable = extension.installed
&& !!extension.installedVersion
&& !!extension.latestVersion
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
&& !versionMismatch;
return (
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
@@ -359,8 +376,25 @@ function ExtensionCard({
</Link>
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
{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">
{extension.installed && updateAvailable ? (
<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" />
Installed
</span>
@@ -3,6 +3,8 @@
import { useEffect, useState } from 'react';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
@@ -286,6 +288,27 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
</div>
)}
<PluginAdminSection pluginId={pluginId} />
</div>
);
}
/**
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
* and the host gives it a dedicated iframe inside the admin panel.
*/
function PluginAdminSection({ pluginId }: { pluginId: string }) {
const offers = usePluginSlotOffers('admin-plugin-page');
const offer = offers.find((o) => o.pluginId === pluginId);
if (!offer) return null;
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Plugin admin panel
</div>
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
</div>
);
}
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
import Link from 'next/link';
import {
ArrowLeft,
ArrowUpCircle,
Download,
Loader2,
Puzzle,
@@ -21,7 +22,7 @@ import {
ChevronUp,
} from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { isVersionSatisfied } from '@/lib/version-compare';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -73,6 +74,7 @@ interface PreviewData {
error: string | null;
};
installed: boolean;
installedVersion: string | null;
}
const RISKY_PERMISSIONS = new Set([
@@ -118,6 +120,8 @@ export default function MarketplacePreviewPage() {
async function handleInstall() {
if (!data) return;
const isUpdate = data.installed;
const targetVersion = data.extension.latestVersion || '1.0.0';
setInstalling(true);
setMessage(null);
try {
@@ -126,20 +130,25 @@ export default function MarketplacePreviewPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: data.extension.slug,
version: data.extension.latestVersion || '1.0.0',
version: targetVersion,
type: data.extension.type,
}),
});
const body = await res.json();
if (res.ok) {
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
setData(prev => prev ? { ...prev, installed: true } : prev);
setMessage({
type: 'success',
text: isUpdate
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
: `"${data.extension.name}" installed${warnings}`,
});
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
} else {
setMessage({ type: 'error', text: body.error || 'Installation failed' });
setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
}
} catch {
setMessage({ type: 'error', text: 'Installation failed - network error' });
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally {
setInstalling(false);
}
@@ -204,6 +213,11 @@ export default function MarketplacePreviewPage() {
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
const updateAvailable = data.installed
&& !!data.installedVersion
&& !!ext.latestVersion
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
&& !versionMismatch;
return (
<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">
<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" />}
{data.installed && (
<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">
{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"
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> Installed
</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 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 ${
@@ -279,6 +304,17 @@ export default function MarketplacePreviewPage() {
<div className="flex flex-wrap items-center gap-2 shrink-0">
{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
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"
+1 -1
View File
@@ -5,7 +5,7 @@ import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager";
import "./globals.css";
import "../globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -101,20 +101,17 @@ export default function SetupWizardPage() {
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
const [stepIndex, setStepIndex] = useState(0);
const [completed, setCompleted] = useState(false);
// Detect synchronously on first client render so we don't flash the loading
// screen before the warning appears. The session cookie is set with the
// Secure flag in production, which browsers silently drop over plain HTTP -
// every subsequent step call then 401s with "Wizard session required".
const [insecureContext] = useState<boolean>(detectInsecureContext);
// Resolved in a post-mount effect, not at render, so the server-rendered
// HTML (where window is absent) matches the client's first paint and
// doesn't trip a hydration mismatch.
const [insecureContext, setInsecureContext] = useState(false);
const [insecureAcknowledged, setInsecureAcknowledged] = useState(false);
useEffect(() => {
setInsecureContext(detectInsecureContext());
}, []);
// ─── Initial status load ────────────────────────────────────────────────
useEffect(() => {
// Skip the status fetch entirely when we're going to render the HTTPS
// notice - the wizard cookie can't survive an HTTP origin anyway.
if (insecureContext) {
setBootstrapping(false);
return;
}
let cancelled = false;
(async () => {
try {
@@ -152,7 +149,7 @@ export default function SetupWizardPage() {
return () => {
cancelled = true;
};
}, [router, insecureContext]);
}, [router]);
// ─── Token submit (welcome step) ────────────────────────────────────────
async function submitToken(token: string) {
@@ -184,8 +181,8 @@ export default function SetupWizardPage() {
}
// ─── Render shell ───────────────────────────────────────────────────────
if (insecureContext) {
return <InsecureContextScreen />;
if (insecureContext && !insecureAcknowledged) {
return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
}
if (bootstrapping) {
@@ -362,7 +359,7 @@ function CompletedScreen() {
);
}
function InsecureContextScreen() {
function InsecureContextScreen({ onContinue }: { onContinue: () => void }) {
const httpsUrl =
typeof window !== 'undefined'
? `https://${window.location.host}${window.location.pathname}${window.location.search}`
@@ -373,29 +370,29 @@ function InsecureContextScreen() {
<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" />
</div>
<h1 className="text-xl font-semibold">HTTPS required for setup</h1>
<p className="text-sm text-muted-foreground mt-2">
The setup wizard signs you in with a <code className="font-mono text-xs">Secure</code> cookie,
which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every
step to fail with <em>Wizard session required</em>.
<h1 className="text-xl font-semibold">You&apos;re running setup over plain HTTP</h1>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
The setup token and admin password you enter here will travel in cleartext.
Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it.
</p>
</div>
<div className="mt-5 text-left text-sm text-muted-foreground 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 && (
<a
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"
<div className="mt-6 space-y-2">
{httpsUrl && (
<a
href={httpsUrl}
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"
>
Try HTTPS
</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"
>
Open over HTTPS
</a>
)}
Continue over HTTP
</button>
</div>
</CenteredCard>
);
}
@@ -743,6 +740,21 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
</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.status === 'jmap_detected' ? (
<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());
}
/**
* The JMAP URL is called directly from the user's browser. A URL that only
* resolves on the operator's machine or LAN (localhost, RFC1918, .local mDNS)
* works during setup but breaks for any real user. Surface a soft warning
* so the operator catches this before going live.
*/
function isPrivateOrLocalHostUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return false;
let host: string;
try {
host = new URL(trimmed).hostname.toLowerCase();
} catch {
return false;
}
// Strip IPv6 brackets, if any.
if (host.startsWith('[') && host.endsWith(']')) {
host = host.slice(1, -1);
}
if (host === 'localhost' || host.endsWith('.localhost')) return true;
if (host.endsWith('.local')) return true;
if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;
// IPv4 literal: only flag the well-known private/loopback/link-local ranges.
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])];
if (a === 10) return true;
if (a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
}
return false;
}
function detectInsecureContext(): boolean {
if (typeof window === 'undefined') return false;
if (window.location.protocol !== 'http:') return false;
// Browsers treat localhost/loopback as "potentially trustworthy" and accept
// Secure cookies even without TLS, so the wizard still works there.
// Secure cookies even without TLS, so the wizard still works there. In dev
// we still want to render the warning so we can preview it without spinning
// up a non-loopback host.
const host = window.location.hostname;
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
const isLoopback =
host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
if (isLoopback && process.env.NODE_ENV !== 'development') {
return false;
}
return true;
+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 -1
View File
@@ -8,7 +8,7 @@ import { logger } from '@/lib/logger';
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
Binary file not shown.
+2 -2
View File
@@ -44,7 +44,7 @@ function sanitizeFilename(name: string): string {
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -114,7 +114,7 @@ export async function POST(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+1 -1
View File
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+5 -5
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 { logger } from '@/lib/logger';
// Strings that count as "no real secret configured" used so the dashboard
// Strings that count as "no real secret configured" - used so the dashboard
// can warn about a placeholder session secret without us ever returning the
// raw value to the client.
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
@@ -20,9 +20,9 @@ const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
* the server so that an XSS or session-theft can't lift them in one
* request and forge admin/user session cookies offline.
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
await configManager.ensureLoaded();
@@ -54,7 +54,7 @@ export async function GET() {
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -109,7 +109,7 @@ export async function PATCH(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+18 -10
View File
@@ -7,8 +7,12 @@ import {
} from '@/lib/admin/plugin-registry';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
import { configManager } from '@/lib/admin/config-manager';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
const MAX_PREVIEW_SOURCE_LEN = 100_000;
@@ -19,17 +23,18 @@ const MAX_PREVIEW_SOURCE_LEN = 100_000;
* Lets admins audit what they're about to install before pressing the button.
*/
export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { slug } = await params;
const directoryUrl = await getDirectoryUrl();
// 1. Extension metadata + screenshots + theme previews from the directory
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
const detailRes = await fetch(detailUrl.toString(), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
@@ -63,7 +68,7 @@ export async function GET(
try {
const bundleUrl = new URL(
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
DIRECTORY_URL,
directoryUrl,
);
const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000),
@@ -144,14 +149,16 @@ export async function GET(
getPluginRegistry(),
getThemeRegistry(),
]);
const installed = type === 'theme'
? themeRegistry.themes.some((t) => t.id === slug)
: pluginRegistry.plugins.some((p) => p.id === slug);
const installedEntry = type === 'theme'
? themeRegistry.themes.find((t) => t.id === slug)
: pluginRegistry.plugins.find((p) => p.id === slug);
const installed = installedEntry !== undefined;
const installedVersion = installedEntry?.version ?? null;
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
const screenshots = Array.isArray(extension.screenshots)
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
altText: s.altText ?? null,
}))
: [];
@@ -170,7 +177,7 @@ export async function GET(
const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null;
return NextResponse.json(
@@ -206,6 +213,7 @@ export async function GET(
error: bundleError,
},
installed,
installedVersion,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
+115 -28
View File
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
import {
savePlugin,
saveTheme,
getPlugin,
getTheme,
getPluginRegistry,
getThemeRegistry,
type ServerPlugin,
@@ -13,13 +15,18 @@ import {
import {
sanitizeFrameOrigins,
sanitizeHttpOrigins,
sanitizeApiPostPaths,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
import { configManager } from '@/lib/admin/config-manager';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
/**
* GET /api/admin/marketplace - Search/browse the extension directory
@@ -27,11 +34,12 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions
*/
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const directoryUrl = await getDirectoryUrl();
const { searchParams } = request.nextUrl;
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
const url = new URL('/api/v1/extensions', directoryUrl);
// Forward all search params
for (const [key, value] of searchParams.entries()) {
@@ -58,23 +66,32 @@ export async function GET(request: NextRequest) {
getThemeRegistry(),
]);
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
const installedPluginVersions = new Map(
pluginRegistry.plugins.map(p => [p.id, p.version] as const),
);
const installedThemeVersions = new Map(
themeRegistry.themes.map(t => [t.id, t.version] as const),
);
const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null;
if (data.data) {
data.data = data.data.map((ext: Record<string, unknown>) => ({
...ext,
iconUrl: fileUrl(ext.iconPath),
bannerUrl: fileUrl(ext.bannerPath),
installed: ext.type === 'theme'
? installedThemes.has(ext.slug as string)
: installedPlugins.has(ext.slug as string),
}));
data.data = data.data.map((ext: Record<string, unknown>) => {
const slug = ext.slug as string;
const installedVersion = ext.type === 'theme'
? installedThemeVersions.get(slug) ?? null
: installedPluginVersions.get(slug) ?? null;
return {
...ext,
iconUrl: fileUrl(ext.iconPath),
bannerUrl: fileUrl(ext.bannerPath),
installed: installedVersion !== null,
installedVersion,
};
});
}
return NextResponse.json(data, {
@@ -92,7 +109,7 @@ export async function GET(request: NextRequest) {
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -107,7 +124,8 @@ export async function POST(request: NextRequest) {
}
// Download the bundle from the directory
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
const directoryUrl = await getDirectoryUrl();
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000),
});
@@ -163,6 +181,18 @@ export async function POST(request: NextRequest) {
const now = new Date().toISOString();
// Resolve and strictly validate the id used as a filename. Marketplace
// bundles are authored by a third-party publisher; without this an id
// like "../../foo" causes savePlugin/saveTheme to write outside the
// plugins/themes dir via path.join.
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
return NextResponse.json(
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
{ status: 400 },
);
}
if (type === 'theme') {
// Read theme.css
const cssFile = zip.file(root + 'theme.css');
@@ -181,22 +211,43 @@ export async function POST(request: NextRequest) {
warnings.push(...sanitized.warnings);
}
const existingTheme = await getTheme(resolvedId);
const isUpdate = existingTheme !== null;
const theme: ServerTheme = {
id: (manifest.id as string) || slug,
id: resolvedId,
name: (manifest.name as string) || slug,
version: (manifest.version as string) || version,
// Prefer the directory-published version (what we requested) over
// manifest.version. Publishers sometimes forget to bump the version
// inside the bundle's manifest.json; trusting it would make the
// update never appear to "stick" — the registry would keep showing
// the older version even after a successful update.
version: version || (manifest.version as string),
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'],
enabled: true,
installedAt: now,
enabled: existingTheme?.enabled ?? true,
...(existingTheme?.forceEnabled !== undefined
? { forceEnabled: existingTheme.forceEnabled }
: {}),
installedAt: existingTheme?.installedAt ?? now,
updatedAt: now,
};
await saveTheme(theme, css);
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
await auditLog(
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
{
id: theme.id,
name: theme.name,
version: theme.version,
slug,
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, theme, warnings });
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
} else {
// Plugin installation
// Read entrypoint JS
@@ -266,17 +317,37 @@ export async function POST(request: NextRequest) {
);
}
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
? (manifest.apiPostPaths as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
)
: [];
if (droppedApiPostPaths.length > 0) {
warnings.push(
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
);
}
const existingPlugin = await getPlugin(resolvedId);
const isUpdate = existingPlugin !== null;
const plugin: ServerPlugin = {
id: (manifest.id as string) || slug,
id: resolvedId,
name: (manifest.name as string) || slug,
version: (manifest.version as string) || version,
// See theme branch: trust the directory-published version, not
// manifest.version, so updates actually stick in the registry.
version: version || (manifest.version as string),
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook',
permissions,
entrypoint,
enabled: true,
installedAt: now,
enabled: existingPlugin?.enabled ?? true,
...(existingPlugin?.forceEnabled !== undefined
? { forceEnabled: existingPlugin.forceEnabled }
: {}),
installedAt: existingPlugin?.installedAt ?? now,
updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
@@ -290,13 +361,29 @@ export async function POST(request: NextRequest) {
...(declaredHttpOrigins.length > 0
? { httpOrigins: declaredHttpOrigins }
: {}),
...(declaredApiPostPaths.length > 0
? { apiPostPaths: declaredApiPostPaths }
: {}),
};
await savePlugin(plugin, code);
invalidateFrameOriginsCache();
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
await auditLog(
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
{
id: plugin.id,
name: plugin.name,
version: plugin.version,
slug,
frameOrigins: declaredFrameOrigins,
httpOrigins: declaredHttpOrigins,
apiPostPaths: declaredApiPostPaths,
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, plugin, warnings });
return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
}
} catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
+1 -1
View File
@@ -84,7 +84,7 @@ function isValidOriginUrl(value: string): boolean {
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
+85
View File
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
/**
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
*
* GET /api/admin/plugin-approvals → list all entries
* POST /api/admin/plugin-approvals → { pluginId, bundleHash, decision: 'approved'|'denied' }
* DELETE /api/admin/plugin-approvals?pluginId=…&bundleHash=… → revoke
*/
function isValidId(s: unknown): s is string {
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
}
function isValidHash(s: unknown): s is string {
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
}
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const entries = await listApprovals();
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
} catch (err) {
logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
const adminUser = 'admin';
void result;
const ip = getClientIP(request);
let body: unknown;
try { body = await request.json(); } catch { body = null; }
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown };
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
if (b.decision !== 'approved' && b.decision !== 'denied') {
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
}
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
return NextResponse.json({ entry });
} catch (err) {
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
const adminUser = 'admin';
void result;
const ip = getClientIP(request);
const pluginId = request.nextUrl.searchParams.get('pluginId');
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
await revokeApproval(pluginId, bundleHash);
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
return NextResponse.json({ ok: true });
} catch (err) {
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+17 -8
View File
@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
import { signBytes } from '@/lib/admin/plugin-signing';
async function safeSign(code: string): Promise<string | null> {
try { return await signBytes(code); } catch { return null; }
}
/**
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
@@ -25,14 +30,15 @@ export async function GET(
const devEntry = await getDevPlugin(id);
if (devEntry) {
const code = await readDevBundle(devEntry);
return new NextResponse(code, {
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store',
'ETag': `"${devEntry.plugin.bundleHash}"`,
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
},
});
const signature = await safeSign(code);
const headers: Record<string, string> = {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store',
'ETag': `"${devEntry.plugin.bundleHash}"`,
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
};
if (signature) headers['X-Bundle-Signature'] = signature;
return new NextResponse(code, { headers });
}
const plugin = await getPlugin(id);
@@ -59,6 +65,9 @@ export async function GET(
};
if (etag) headers['ETag'] = etag;
const signature = await safeSign(code);
if (signature) headers['X-Bundle-Signature'] = signature;
if (etag && request.headers.get('if-none-match') === etag) {
return new NextResponse(null, { status: 304, headers });
}
+21 -9
View File
@@ -34,7 +34,7 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const adminAuth = await requireAdminAuth();
const adminAuth = await requireAdminAuth(request);
const isAdmin = !('error' in adminAuth);
if (!isAdmin) {
@@ -51,13 +51,18 @@ export async function GET(
const config = await getPluginConfig(id);
let response: Record<string, unknown> = config;
if (!isAdmin && plugin.configSchema) {
let response: Record<string, unknown>;
if (isAdmin) {
response = config;
} else {
response = {};
for (const [key, value] of Object.entries(config)) {
const field = plugin.configSchema[key];
if (field?.type === 'secret') continue;
response[key] = value;
const schema = plugin.configSchema;
if (schema) {
for (const [key, value] of Object.entries(config)) {
const field = schema[key];
if (!field || field.type === 'secret') continue;
response[key] = value;
}
}
}
@@ -80,7 +85,7 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { id } = await params;
@@ -110,6 +115,13 @@ export async function PUT(
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
if (plugin.configSchema && !plugin.configSchema[body.key]) {
return NextResponse.json(
{ error: 'Key is not declared in the plugin configSchema' },
{ status: 400 },
);
}
await setPluginConfig(id, body.key, body.value);
return NextResponse.json({ ok: true });
} catch {
@@ -127,7 +139,7 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> },
) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const { id } = await params;
+22 -8
View File
@@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev';
import {
sanitizeFrameOrigins,
sanitizeHttpOrigins,
sanitizeApiPostPaths,
invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins';
@@ -31,9 +32,9 @@ const SUSPICIOUS_JS_PATTERNS = [
/**
* GET /api/admin/plugins - List all admin-managed plugins
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const [registry, devEntries] = await Promise.all([
@@ -63,7 +64,7 @@ export async function GET() {
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -172,6 +173,7 @@ export async function POST(request: NextRequest) {
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
const now = new Date().toISOString();
const plugin: ServerPlugin = {
@@ -196,13 +198,16 @@ export async function POST(request: NextRequest) {
...(declaredHttpOrigins.length > 0
? { httpOrigins: declaredHttpOrigins }
: {}),
...(declaredApiPostPaths.length > 0
? { apiPostPaths: declaredApiPostPaths }
: {}),
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
return NextResponse.json({ plugin });
} catch (error) {
@@ -217,7 +222,7 @@ export async function POST(request: NextRequest) {
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -235,9 +240,18 @@ export async function PATCH(request: NextRequest) {
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
const updated = await updatePluginMeta(id, updates);
let updated = await updatePluginMeta(id, updates);
if (!updated) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
// Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
// client. Skip the registry write and return the live dev plugin so the
// policy save path can proceed.
const devEntries = await listDevPlugins();
const devEntry = devEntries.find(e => e.plugin.id === id);
if (!devEntry) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
updated = { ...devEntry.plugin, ...updates };
}
// Enable/disable changes the set of plugins contributing frame origins.
@@ -259,7 +273,7 @@ export async function PATCH(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+1 -1
View File
@@ -26,7 +26,7 @@ export async function GET() {
*/
export async function PUT(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+3 -3
View File
@@ -19,9 +19,9 @@ import {
* Returns current consent + endpoint + next/last send + a live preview
* of exactly what the next heartbeat would contain.
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent();
@@ -61,7 +61,7 @@ export async function GET() {
*/
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
+5 -5
View File
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
/**
* GET /api/admin/themes - List all admin-managed themes
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const registry = await getThemeRegistry();
@@ -36,7 +36,7 @@ export async function GET() {
*/
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
*/
export async function PATCH(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
*/
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth();
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const ip = getClientIP(request);
+3 -3
View File
@@ -13,9 +13,9 @@ import {
* GET /api/admin/version
* Returns the cached update status, last check times, and effective config.
*/
export async function GET() {
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const state = await loadState();
@@ -47,7 +47,7 @@ export async function GET() {
*/
export async function POST(req: NextRequest) {
try {
const auth = await requireAdminAuth();
const auth = await requireAdminAuth(req);
if ('error' in auth) return auth.error;
const body = (await req.json().catch(() => null)) as { action?: string } | null;
+3 -3
View File
@@ -23,7 +23,7 @@ const IMPERSONATION_SLOT = 0;
/**
* Impersonation cookies deliberately omit Max-Age so the browser treats
* them as session cookies the impersonated session ends when the user
* them as session cookies - the impersonated session ends when the user
* closes the browser, not 30 days later. Impersonation is a temporary
* support handoff; a normal password login is the only thing that should
* survive a browser restart.
@@ -48,7 +48,7 @@ function impersonationCookieOptions() {
export async function GET(request: NextRequest) {
const config = readImpersonationConfig();
if (!config) {
// Not configured behave exactly like an unknown route.
// Not configured - behave exactly like an unknown route.
return new NextResponse('Not found', { status: 404 });
}
@@ -112,7 +112,7 @@ export async function GET(request: NextRequest) {
authHeader,
});
// Structured audit log operators rely on this for security review.
// Structured audit log - operators rely on this for security review.
logger.info('Impersonation session granted', {
event: 'impersonation_granted',
jti: claims.jti,
+44 -11
View File
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
import {
exchangeCodeForTokens,
getRequiredConfig,
getTokenEndpoint,
} from '@/lib/oauth/token-exchange';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
@@ -56,6 +60,10 @@ export async function POST(request: NextRequest) {
const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
const mobileRedirectUri =
typeof pending.mobile_redirect_uri === 'string' ? pending.mobile_redirect_uri : null;
const mobileState = typeof pending.mobile_state === 'string' ? pending.mobile_state : null;
const isMobileFlow = Boolean(mobileRedirectUri);
if (!codeVerifier || !redirectUri) {
cookieStore.delete(SSO_PENDING_COOKIE);
@@ -65,21 +73,46 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
// Store refresh token in the per-account cookie slot.
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
} else {
cookieStore.delete(serverCookieName);
// For the mobile handoff flow the tokens are handed back to the app
// verbatim - we deliberately don't write any cookies on the webmail
// origin (the mobile browser tab disposes of the session after the
// redirect anyway, but the cookie would still get committed to the
// user's main webmail session if they happened to be logged in there).
if (!isMobileFlow) {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
} else {
cookieStore.delete(serverCookieName);
}
}
// Delete pending cookie
cookieStore.delete(SSO_PENDING_COOKIE);
if (isMobileFlow) {
// The mobile client needs the bits it can't re-derive: the refresh
// token, the token endpoint it should hit to refresh later, and the
// client_id the IdP expects on that refresh call. The server URL is
// returned so the app knows which JMAP host to connect to.
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
const tokenEndpoint = await getTokenEndpoint(pendingServerId);
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
refresh_token: tokens.refresh_token,
token_endpoint: tokenEndpoint,
client_id: clientId,
server_url: serverUrl,
mobile_redirect_uri: mobileRedirectUri,
mobile_state: mobileState,
});
}
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
+30 -2
View File
@@ -5,6 +5,7 @@ import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
@@ -12,18 +13,40 @@ import { hasSessionSecret } from '@/lib/auth/session-secret';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
// The mobile app's deep-link scheme. Only redirect targets starting with
// this prefix may flow through the mobile handoff path; without the guard
// the SSO complete route would be coerced into returning tokens to whatever
// caller-controlled URL the attacker chose.
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
export async function POST(request: NextRequest) {
try {
if (!hasSessionSecret()) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
}
const { redirect_uri, locale, server_id: bodyServerId } = await request.json();
const {
redirect_uri,
locale,
server_id: bodyServerId,
mobile_redirect_uri: rawMobileRedirectUri,
mobile_state: rawMobileState,
} = await request.json();
if (!redirect_uri || typeof redirect_uri !== 'string') {
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
}
const mobileRedirectUri =
typeof rawMobileRedirectUri === 'string' && rawMobileRedirectUri
? rawMobileRedirectUri
: null;
const mobileState =
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) {
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
}
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
// Validate redirect_uri origin matches the request origin to prevent open redirects
@@ -39,7 +62,7 @@ export async function POST(request: NextRequest) {
}
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
if (!metadata?.authorization_endpoint) {
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
@@ -52,12 +75,17 @@ export async function POST(request: NextRequest) {
// Encrypt and store in httpOnly cookie. server_id is captured here so the
// /complete handler reaches the same OAuth endpoint we used to authorize.
// Mobile params are captured here so /complete knows to return tokens to
// the caller (in the JSON response) instead of writing the usual server
// cookies - and so the callback page can redirect back to the app.
const pendingData = {
state,
code_verifier: codeVerifier,
redirect_uri,
created_at: Date.now(),
...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
};
const encrypted = encryptPayload(pendingData);
+17 -10
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { JmapAuthVerificationError, assertBasicAuthMatchesUsername, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
@@ -57,15 +57,22 @@ export async function POST(request: NextRequest) {
}
const slot = getSlot(request, bodySlot);
// Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
// just authenticated to JMAP with these credentials, and the cookie we
// write here is only ever consumed for requests on behalf of this same
// user - a bogus auth header would just yield 401s downstream, not
// privilege escalation. For untrusted custom endpoints we still verify
// upstream as before.
const normalizedServerUrl = upstreamTrusted
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
// Trusted (admin-configured) URLs skip the upstream re-fetch, but we
// still bind the cookie's `username` to the credential when we can verify
// locally. Without this, a caller can POST username="admin@host" +
// authHeader=<their own Basic creds>, and downstream consumers that read
// the cookie-derived username (audit logs, login tracker) accept the
// spoof. Bearer tokens are opaque so only the format check runs;
// authorization sinks must key off the credential itself, not the
// cookie's username claim (see admin/auth's authHeader-hashed cache key).
let normalizedServerUrl: string;
if (upstreamTrusted) {
validateProxyAuthHeader(authHeader);
assertBasicAuthMatchesUsername(authHeader, username);
normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl);
} else {
normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
}
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
+1 -1
View File
@@ -54,7 +54,7 @@ async function tryTokenRequest(
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
// 1. Try OAuth discovery
const metadata = await discoverOAuth(serverUrl);
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { logger } from '@/lib/logger';
import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals';
/**
* GET /api/plugin-approval-status?pluginId=X&bundleHash=Y
*
* Any logged-in user may query the server-side approval state for a plugin
* they want to enable. The client uses this BEFORE running `enablePlugin`
* when the `requirePluginApproval` policy is set.
*
* POST same path with body `{ pluginId, bundleHash, manifest }` creates a
* pending approval entry (or returns the existing one).
*/
async function resolveUsername(): Promise<string | null> {
const cookieStore = await cookies();
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) {
const sess = decryptSession(token);
if (sess?.username) return sess.username;
}
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx?.username) return ctx.username;
}
return null;
}
function isValidId(s: unknown): s is string {
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
}
function isValidHash(s: unknown): s is string {
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
}
export async function GET(request: NextRequest) {
try {
const username = await resolveUsername();
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
const pluginId = request.nextUrl.searchParams.get('pluginId');
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
const status = await getApprovalStatus(pluginId, bundleHash);
return NextResponse.json(status, { headers: { 'Cache-Control': 'no-store' } });
} catch (err) {
logger.error('plugin-approval-status GET', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const username = await resolveUsername();
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
let body: unknown;
try { body = await request.json(); } catch { body = null; }
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown };
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
const m = (b.manifest ?? {}) as Record<string, unknown>;
const manifest: ApprovalEntry['manifest'] = {
name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined,
version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined,
author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined,
description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined,
permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined,
httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
};
const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username);
return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt });
} catch (err) {
logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { getPublicKeyBase64 } from '@/lib/admin/plugin-signing';
import { logger } from '@/lib/logger';
/**
* GET /api/plugin-signing-pubkey
*
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
* sandboxed plugin loader can verify bundle signatures before evaluation.
* Public - every logged-in user needs to fetch it on app boot.
*
* 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
* client caches the result in memory for the lifetime of the page.
*/
export async function GET() {
try {
const publicKey = await getPublicKeyBase64();
return NextResponse.json(
{ algorithm: 'ed25519', publicKey },
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('[plugin-signing-pubkey] load failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Signing key unavailable' }, { status: 500 });
}
}
+12 -1
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
/**
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
*/
export async function GET() {
try {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
getPluginRegistry(),
getThemeRegistry(),
@@ -34,7 +39,11 @@ export async function GET() {
type: p.type,
permissions: p.permissions,
entrypoint: p.entrypoint,
forceEnabled: p.forceEnabled || false,
// Policy is the canonical source for force-enable. The per-plugin field
// can drift for dev plugins (manifest always loads forceEnabled:false)
// and during pending policy saves; OR'ing here unifies the signal so
// the client's auto-enable path triggers consistently.
forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id),
// Content hash + updatedAt let clients detect re-uploads even when
// the manifest version is unchanged.
bundleHash: p.bundleHash,
@@ -43,6 +52,8 @@ export async function GET() {
dev: p.dev,
// Surface so clients can enforce api.http.fetch origin allowlists.
httpOrigins: p.httpOrigins,
// Surface so clients can enforce api.http.post path allowlists.
apiPostPaths: p.apiPostPaths,
// Per-user settings schema, captured from the manifest at upload/load
// time so the client can render the settings UI without re-parsing.
settingsSchema: p.settingsSchema,
+6 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
const VALID_SIZES = new Set([192, 512]);
@@ -32,7 +33,11 @@ export async function GET(
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
}
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
await configManager.ensureLoaded();
const sources = configManager.getAllWithSources();
const iconUrl =
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
if (!iconUrl) {
return new NextResponse('No PWA icon configured', { status: 404 });
}
+1 -1
View File
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
try {
// 1. Provision the admin account. An admin.json file may already exist
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
// run while setupComplete is still false accept the wizard's
// run while setupComplete is still false - accept the wizard's
// password as authoritative in that case. The finish route is gated
// by the bootstrap state + one-time setup token, so this is safe.
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
+1 -1
View File
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
}
const response = NextResponse.json({ ok: true });
const attrs = buildSessionCookieAttributes();
const attrs = buildSessionCookieAttributes(request);
response.cookies.set(attrs.name, submitted, {
httpOnly: attrs.httpOnly,
sameSite: attrs.sameSite,
+7
View File
@@ -240,6 +240,13 @@ body {
padding: 1rem 1.25rem;
}
@media (max-width: 640px) {
.email-content-text {
padding-left: 0;
padding-right: 0;
}
}
.email-content-text a {
color: #2563eb;
text-decoration: underline;
+16 -9
View File
@@ -1,4 +1,5 @@
import type { MetadataRoute } from "next";
import { configManager } from "@/lib/admin/config-manager";
export const dynamic = "force-dynamic";
@@ -21,22 +22,28 @@ type ExtendedManifest = MetadataRoute.Manifest & {
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
const withBase = (p: string) => `${BASE_PATH}${p}`;
export default function manifest(): ExtendedManifest {
export default async function manifest(): Promise<ExtendedManifest> {
await configManager.ensureLoaded();
const appName =
process.env.APP_NAME ||
configManager.get<string>("appName") ||
process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail";
const shortName = process.env.APP_SHORT_NAME || appName;
const shortName = configManager.get<string>("appShortName") || appName;
const description =
process.env.APP_DESCRIPTION ||
configManager.get<string>("appDescription") ||
"A modern webmail client built for Stalwart Mail Server";
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
// are the built-in placeholder paths and not real custom icons.
const sources = configManager.getAllWithSources();
const hasCustomIcon =
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [
+214 -32
View File
@@ -2,19 +2,49 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store";
import { useAccountStore } from "@/stores/account-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
/**
* Split a per-account calendar list into "owned" (the user's own) and
* "shared" sub-buckets, then group shared by the owning principal so each
* delegator gets its own sub-section.
*/
type AccountCalendarSplit = {
owned: Calendar[];
sharedGroups: { label: string; calendars: Calendar[] }[];
};
function splitAccountCalendars(list: Calendar[]): AccountCalendarSplit {
const owned: Calendar[] = [];
const sharedBuckets = new Map<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 {
calendars: Calendar[];
selectedCalendarIds: string[];
@@ -28,6 +58,12 @@ interface CalendarSidebarPanelProps {
onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
/**
* When true, render one collapsible section per connected local account,
* mirroring the mail sidebar's Pro-shell layout. Calendars are bucketed
* by their `localAccountId` and the active account is shown first.
*/
multiAccountMode?: boolean;
}
export function CalendarSidebarPanel({
@@ -43,12 +79,18 @@ export function CalendarSidebarPanel({
onSubscribe,
onEditSubscription,
client,
multiAccountMode,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription");
const tMgmt = useTranslations("calendar.management");
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
const allSubs = useCalendarStore((s) => s.icalSubscriptions);
const currentAccountId = client?.getAccountId();
const icalSubscriptions = useMemo(
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
[allSubs, currentAccountId],
);
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
const timeFormat = useSettingsStore((s) => s.timeFormat);
@@ -65,6 +107,26 @@ export function CalendarSidebarPanel({
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
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 sharedAccountGroups = useMemo(() => {
const shared = calendars.filter(c => c.isShared);
@@ -79,6 +141,53 @@ export function CalendarSidebarPanel({
return Array.from(groups.values());
}, [calendars]);
/**
* Pro / multi-account grouping: every calendar bucketed by its owning
* local account. Active account comes first, then the rest in their
* account-store order. Calendars without a `localAccountId` (e.g. the
* birthday calendar) fall into a separate "other" bucket so they still
* render.
*/
const localAccountGroups = useMemo(() => {
if (!multiAccountMode) return [];
const byAccount = new Map<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) => {
return icalSubscriptions.find(s => s.calendarId === calendarId);
};
@@ -263,37 +372,110 @@ export function CalendarSidebarPanel({
)}
</button>
)}
<div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? (
<button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
<div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)}
</div>
{sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.calendars.map(renderCalendarItem)}
{multiAccountMode && localAccountGroups.length > 0 ? (
<>
{localAccountGroups.map((group, idx) => {
const expanded = !collapsedAccountGroups.has(group.key);
const isActive = group.key === activeLocalAccountId;
const { owned, sharedGroups } = group.split;
return (
<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">
{onCreateCalendar ? (
<button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
</div>
))}
<div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)}
</div>
{sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.calendars.map(renderCalendarItem)}
</div>
</div>
))}
</>
)}
{renderCalendarMenu()}
</div>
+25 -1
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -26,6 +26,8 @@ interface CalendarToolbarProps {
selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void;
enableCalendarTasks?: boolean;
/** Show a burger button at the start that opens the (overlay) sidebar. */
onMenuClick?: () => void;
}
export function CalendarToolbar({
@@ -45,6 +47,7 @@ export function CalendarToolbar({
selectedCalendarIds,
onToggleVisibility,
enableCalendarTasks,
onMenuClick,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const formatter = useFormatter();
@@ -115,11 +118,32 @@ export function CalendarToolbar({
return (
<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 ── */}
{isMobile && (
<div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */}
<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 && (
<button
onClick={onNavigateBack}
+190 -70
View File
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "lucide-react";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -76,7 +76,65 @@ function buildDuration(startDate: Date, endDate: Date): string {
}
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
interface AlertRow {
id: string;
value: number;
unit: AlertUnit;
}
let alertRowSeq = 0;
function newAlertRow(value: number, unit: AlertUnit): AlertRow {
alertRowSeq += 1;
return { id: `r${alertRowSeq}`, value, unit };
}
function alertRowToOffset(row: AlertRow): string | null {
if (row.unit === "at_time") return "PT0S";
const v = Math.max(0, Math.floor(row.value));
if (!Number.isFinite(v) || v <= 0) return null;
switch (row.unit) {
case "minutes": return `-PT${v}M`;
case "hours": return `-PT${v}H`;
case "days": return `-P${v}D`;
case "weeks": return `-P${v}W`;
}
}
function offsetToAlertRow(offset: string): AlertRow | null {
if (offset === "PT0S" || offset === "P0D" || offset === "PT0M") {
return newAlertRow(0, "at_time");
}
let m = offset.match(/^-?P(\d+)W$/);
if (m) return newAlertRow(parseInt(m[1], 10), "weeks");
m = offset.match(/^-?P(\d+)D$/);
if (m) return newAlertRow(parseInt(m[1], 10), "days");
m = offset.match(/^-?PT(\d+)H$/);
if (m) return newAlertRow(parseInt(m[1], 10), "hours");
m = offset.match(/^-?PT(\d+)M$/);
if (m) {
const mins = parseInt(m[1], 10);
if (mins > 0 && mins % 1440 === 0) return newAlertRow(mins / 1440, "days");
if (mins > 0 && mins % 60 === 0) return newAlertRow(mins / 60, "hours");
return newAlertRow(mins, "minutes");
}
return null;
}
function formatAlertRowLabel(
row: { value: number; unit: AlertUnit },
t: ReturnType<typeof useTranslations>
): string {
if (row.unit === "at_time") return t("alerts.at_time");
switch (row.unit) {
case "minutes": return t("alerts.minutes_before", { count: row.value });
case "hours": return t("alerts.hours_before", { count: row.value });
case "days": return t("alerts.days_before", { count: row.value });
case "weeks": return t("alerts.weeks_before", { count: row.value });
}
}
function formatDurationDisplay(minutes: number): string {
if (minutes < 60) return `${minutes}min`;
@@ -88,17 +146,15 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
return null;
const labels: string[] = [];
for (const alert of Object.values(event.alerts)) {
if (alert.trigger["@type"] !== "OffsetTrigger") continue;
const row = offsetToAlertRow(alert.trigger.offset);
if (!row) continue;
labels.push(formatAlertRowLabel(row, t));
}
if (labels.length === 0) return null;
return labels.join(", ");
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
@@ -216,22 +272,36 @@ export function EventModal({
if (!event?.recurrenceRules?.length) return "none";
return event.recurrenceRules[0].frequency as RecurrenceOption;
});
const [alert, setAlert] = useState<AlertOption>(() => {
if (!event?.alerts) return "none";
const first = Object.values(event.alerts)[0];
if (!first) return "none";
if (first.trigger["@type"] === "OffsetTrigger") {
const offset = first.trigger.offset;
if (offset === "PT0S") return "at_time";
const minMatch = offset.match(/-?PT(\d+)M$/);
if (minMatch) return minMatch[1] as AlertOption;
const hourMatch = offset.match(/-?PT(\d+)H$/);
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
if (!event?.alerts) return [];
const rows: AlertRow[] = [];
for (const [id, alert] of Object.entries(event.alerts)) {
// Preserve alerts we can't represent in this UI (absolute triggers,
// email actions, offsets with non-canonical shapes) so they survive a save.
if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
preservedAlertsRef.current[id] = alert;
continue;
}
const row = offsetToAlertRow(alert.trigger.offset);
if (!row) {
preservedAlertsRef.current[id] = alert;
continue;
}
rows.push(row);
}
return "none";
return rows;
});
const addAlertRow = useCallback(() => {
setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]);
}, []);
const updateAlertRow = useCallback((id: string, patch: Partial<Omit<AlertRow, "id">>) => {
setAlertRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}, []);
const removeAlertRow = useCallback((id: string) => {
setAlertRows((prev) => prev.filter((r) => r.id !== id));
}, []);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -400,17 +470,23 @@ export function EventModal({
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
}
if (alert !== "none") {
const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`;
data.alerts = {
alert1: {
"@type": "Alert",
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
action: "display",
acknowledged: null,
relatedTo: null,
},
const builtAlerts: Record<string, CalendarEventAlert> = { ...preservedAlertsRef.current };
let alertIdx = 0;
for (const row of alertRows) {
const offset = alertRowToOffset(row);
if (offset === null) continue;
let key = `alert${++alertIdx}`;
while (key in builtAlerts) key = `alert${++alertIdx}`;
builtAlerts[key] = {
"@type": "Alert",
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
action: "display",
acknowledged: null,
relatedTo: null,
};
}
if (Object.keys(builtAlerts).length > 0) {
data.alerts = builtAlerts;
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
data.alerts = null;
}
@@ -435,7 +511,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
@@ -987,37 +1063,81 @@ export function EventModal({
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
<select
value={alert}
onChange={(e) => setAlert(e.target.value as AlertOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("alerts.none")}</option>
<option value="at_time">{t("alerts.at_time")}</option>
<option value="5">{t("alerts.minutes_before", { count: 5 })}</option>
<option value="15">{t("alerts.minutes_before", { count: 15 })}</option>
<option value="30">{t("alerts.minutes_before", { count: 30 })}</option>
<option value="60">{t("alerts.hours_before", { count: 1 })}</option>
<option value="1440">{t("alerts.days_before", { count: 1 })}</option>
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="none">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
<div>
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
{alertRows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
) : (
<div className="space-y-2">
{alertRows.map((row) => (
<div key={row.id} className="flex items-center gap-2">
{row.unit !== "at_time" && (
<Input
type="number"
min={1}
max={999}
value={row.value}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
updateAlertRow(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 });
}}
className="w-20"
aria-label={t("alerts.amount")}
/>
)}
<select
value={row.unit}
onChange={(e) => {
const unit = e.target.value as AlertUnit;
updateAlertRow(row.id, {
unit,
value: unit === "at_time" ? 0 : (row.value || 1),
});
}}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={t("alerts.unit")}
>
<option value="at_time">{t("alerts.at_time")}</option>
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
<option value="hours">{t("alerts.unit_hours_before")}</option>
<option value="days">{t("alerts.unit_days_before")}</option>
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
</select>
<button
type="button"
onClick={() => removeAlertRow(row.id)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("alerts.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
<button
type="button"
onClick={addAlertRow}
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Plus className="w-4 h-4" />
{t("alerts.add")}
</button>
</div>
{attendees.length > 0 && (
@@ -78,7 +78,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
} finally {
setIsSubmitting(false);
}
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
}, [url, name, color, refreshInterval, client, isEdit, editSubscription, addICalSubscription, updateICalSubscription, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
+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 { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { normalizeContactPhotoUri } from "@/stores/contact-store";
import { cn } from "@/lib/utils";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
@@ -51,6 +52,8 @@ interface ContactFormProps {
addressBooks?: AddressBook[];
allKeywords?: string[];
defaultAddressBookId?: string;
/** Prefills the create form (ignored when `contact` is set). */
prefill?: { email?: string; name?: string };
onSave: (data: Partial<ContactCard>) => Promise<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 isEditing = !!contact;
// Split a free-form display name into given/surname for prefill.
const prefillGivenName = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts[0] || "";
})();
const prefillSurname = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts.slice(1).join(" ");
})();
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const findComponent = (...kinds: string[]) =>
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
@@ -214,9 +229,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
}
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
const [givenName, setGivenName] = useState(findComponent("given"));
const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName);
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
const [surname, setSurname] = useState(findComponent("surname"));
const [surname, setSurname] = useState(findComponent("surname") || prefillSurname);
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
const [nickname, setNickname] = useState(
@@ -230,7 +245,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
}));
}
return [{ address: "", context: "" }];
return [{ address: prefill?.email || "", context: "" }];
});
const [phones, setPhones] = useState<PhoneEntry[]>(() => {
@@ -341,7 +356,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const initialPhotoEntry = useMemo(() => {
if (!contact?.media) return null;
for (const [key, m] of Object.entries(contact.media)) {
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
if (m.kind === "photo" && m.uri) {
return { key, uri: normalizeContactPhotoUri(m.uri, m.mediaType), mediaType: m.mediaType };
}
}
return null;
}, [contact]);
+14 -1
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
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 { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
@@ -112,6 +112,8 @@ interface ContactListProps {
onEditContact: (id: string) => void;
onDeleteContact: (contact: ContactCard) => void;
onAddContactToGroup: (id: string) => void;
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
onMenuClick?: () => void;
}
export function ContactList({
@@ -133,6 +135,7 @@ export function ContactList({
onEditContact,
onDeleteContact,
onAddContactToGroup,
onMenuClick,
}: ContactListProps) {
const t = useTranslations("contacts");
const locale = useLocale();
@@ -267,6 +270,16 @@ export function ContactList({
<div className="border-b border-border bg-background">
<div className="px-3 py-3">
<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
type="button"
onClick={() => {
+186 -42
View File
@@ -2,7 +2,7 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
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 { Button } from "@/components/ui/button";
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 type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
import { useAccountStore } from "@/stores/account-store";
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
@@ -32,6 +33,36 @@ interface ContactsSidebarProps {
onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
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";
@@ -70,6 +101,7 @@ export function ContactsSidebar({
onDeleteAddressBook,
onRenameKeyword,
className,
multiAccountMode,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
const router = useRouter();
@@ -136,6 +168,47 @@ export function ContactsSidebar({
return Array.from(map.values());
}, [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
const contactCountByBook = useMemo(() => {
const counts: Record<string, number> = {};
@@ -257,47 +330,117 @@ export function ContactsSidebar({
</span>
</button>
{/* My Address Books */}
{personalBooks.length > 0 && (
<div className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
{/* Address Books: per-account groups in multi-account Pro mode, else the
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>
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
);
})
) : (
personalBooks.length > 0 && (
<div className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed.addressBooks && personalBooks.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>
{!collapsed.addressBooks && personalBooks.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>
)
)}
{/* Groups section */}
@@ -398,8 +541,9 @@ export function ContactsSidebar({
)}
</div>
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
{/* Shared accounts with address books - only when not already split
into per-account groups above (multi-account Pro mode). */}
{!multiAccountMode && sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
@@ -389,7 +389,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionError(null);
try {
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback in
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback - in
// parallel with parsing to save a roundtrip.
const [events, rawText] = await Promise.all([
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
@@ -420,7 +420,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setState('parsed');
// Hydrate the calendar store with the matching event in the background
// Hydrate the calendar store with the matching event in the background -
// only needed for the "already in calendar" pill, must not block the banner.
// Filter by UID server-side; the previous unfiltered query fetched up to
// 1000 events plus multiple /get batches just to find one match.
+171 -27
View File
@@ -9,11 +9,12 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store";
@@ -75,6 +76,11 @@ interface EmailComposerProps {
fromName?: string;
identityId?: 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 }>;
inReplyTo?: string[];
references?: string[];
@@ -103,6 +109,14 @@ interface EmailComposerProps {
messageId?: string;
inReplyTo?: string[];
references?: string[];
// Pre-built quote header block. Supplied by the composer opener after it
// runs emailHooks.onBuildQuoteHeader through plugin transforms. When set,
// the composer uses these verbatim instead of building its own default
// "On X, Y wrote:" / "---------- Forwarded message ----------" block.
quoteHeaderHtml?: string;
quoteHeaderText?: string;
/** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */
quoteWrapInBlockquote?: boolean;
};
}
@@ -137,7 +151,7 @@ function buildEmbeddedSignatureHtml(
: `<p data-signature-block="start"></p>`;
const endMarker = `<p data-signature-block="end"></p>`;
if (identity?.htmlSignature) {
return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`;
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
}
if (identity?.textSignature) {
const escaped = identity.textSignature
@@ -171,8 +185,18 @@ export function EmailComposer({
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
const activeIdentities = useIdentityStore((s) => s.identities);
// 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
// body for "above quote" mode. Mirrors the signatureIdentity derivation
@@ -246,6 +270,12 @@ export function EmailComposer({
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
: '';
// Plugin override (resolved at composer open via onBuildQuoteHeader).
if (replyTo.quoteHeaderText !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const body = mode === 'forward' ? originalText : quotedText;
return `${prefix}${signatureBlock}\n\n${replyTo.quoteHeaderText}\n${body}`;
}
if (mode === 'forward') {
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
} else if (mode === 'reply' || mode === 'replyAll') {
@@ -266,6 +296,19 @@ export function EmailComposer({
separator: signatureSeparatorEnabled,
});
// Plugin override (resolved at composer open via onBuildQuoteHeader).
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const wrap = replyTo.quoteWrapInBlockquote !== false;
const originalHtml = replyTo.htmlBody
?? (replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
: '');
const bodyHtml = wrap
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
: originalHtml;
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
}
// Build quoted content as HTML
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const quoteHeader = mode === 'forward'
@@ -293,9 +336,17 @@ export function EmailComposer({
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
// Mirror of draftId for synchronous reads inside chained saves; React's
// setDraftId is async, so a queued saveDraft would otherwise see the old
// value and try to destroy a draft that was just replaced.
const draftIdRef = useRef<string | null>(initialData?.draftId ?? null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
// Tracks the currently-running saveDraft so concurrent callers (autosave
// timer + send button) serialize instead of issuing parallel destroy/create
// requests with the same draftId. See bug #303.
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
if (mode === 'forward' && replyTo?.attachments?.length) {
return replyTo.attachments
@@ -354,6 +405,19 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || 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
// identity's signature so replies (which auto-select a matching alias) still
// populate the user's signature.
@@ -471,7 +535,7 @@ export function EmailComposer({
]);
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
@@ -870,8 +934,8 @@ export function EmailComposer({
};
// Auto-save draft functionality
const saveDraft = async (): Promise<string | null> => {
if (!client) return null;
const saveDraftOnce = async (): Promise<string | null> => {
if (!client || !composerClient) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
@@ -896,7 +960,7 @@ export function EmailComposer({
// Only save if data has changed
if (currentData === lastSavedDataRef.current) {
return draftId;
return draftIdRef.current;
}
setSaveStatus('saving');
@@ -916,20 +980,27 @@ export function EmailComposer({
: (currentIdentity?.name || undefined);
try {
const savedDraftId = await client.createDraft(
const previousDraftId = draftIdRef.current;
// Use the JMAP client and raw identity id for the *owning* account
// - falls back to active client for single-account / same-account
// identities. See `composerClient` derivation above.
const savedDraftId = await composerClient.createDraft(
toAddresses,
subject || t('no_subject'),
plainTextMode ? body : htmlToPlainText(body),
ccAddresses,
bccAddresses,
currentIdentity?.id,
currentIdentityRawId,
fromEmail,
draftId || undefined,
previousDraftId || undefined,
uploadedAttachments,
fromName,
plainTextMode ? undefined : body
);
// Update the ref synchronously so a queued save sees the new id and
// doesn't try to destroy the just-replaced draft.
draftIdRef.current = savedDraftId;
setDraftId(savedDraftId);
lastSavedDataRef.current = currentData;
setSaveStatus('saved');
@@ -946,6 +1017,28 @@ export function EmailComposer({
}
};
// Serialize saves: each call waits for the previous in-flight save before
// running. This prevents the autosave timer and the send button from
// racing two `Email/set { destroy, create }` requests against the same
// draftId, which left orphan drafts and (when EmailSubmission failed)
// looked like "send didn't happen" (#303).
const saveDraft = (): Promise<string | null> => {
const previous = inflightSaveRef.current;
const promise = (async (): Promise<string | null> => {
if (previous) {
try { await previous; } catch { /* prior failure already reported */ }
}
return saveDraftOnce();
})();
inflightSaveRef.current = promise;
promise.finally(() => {
if (inflightSaveRef.current === promise) {
inflightSaveRef.current = null;
}
});
return promise;
};
// Keep saveDraftRef pointing to latest saveDraft
saveDraftRef.current = saveDraft;
@@ -963,6 +1056,10 @@ export function EmailComposer({
// Set new timeout for auto-save (2 seconds after last change)
saveTimeoutRef.current = setTimeout(() => {
// Clear the ref so handleSend can distinguish "save scheduled" from
// "save in flight" - the former still needs flushing, the latter is
// tracked via inflightSaveRef.
saveTimeoutRef.current = null;
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean),
@@ -1086,14 +1183,28 @@ export function EmailComposer({
}
}
let finalDraftId = draftId;
// Resolve the freshest draftId we can. Two cases:
// 1. An autosave is currently in flight - wait for it; don't issue a
// parallel destroy/create that would race with it on the same id.
// 2. A debounced save is scheduled (timer set) - cancel it and flush
// now so the latest body content lands on the server.
// Use draftIdRef (not the React state) because state updates from
// the in-flight save may not have rendered yet when we read here.
let finalDraftId = draftIdRef.current;
if (inflightSaveRef.current) {
try {
const savedId = await inflightSaveRef.current;
if (savedId) finalDraftId = savedId;
} catch (err) {
debug.error('In-flight draft save failed before send:', err);
}
}
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
try {
const savedId = await saveDraft();
if (savedId) {
finalDraftId = savedId;
}
if (savedId) finalDraftId = savedId;
} catch (err) {
debug.error('Failed to save draft before send:', err);
}
@@ -1128,7 +1239,7 @@ export function EmailComposer({
if (signatureAlreadyInBody) return '';
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
if (signatureIdentity?.htmlSignature) {
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
}
if (signatureIdentity?.textSignature) {
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
@@ -1176,6 +1287,13 @@ export function EmailComposer({
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
// S/MIME keys are scoped to one JMAP account's identity - sending
// 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
if (smimeSign_ && !smimeKeyRecord) {
throw new Error('No S/MIME key bound to this identity');
@@ -1321,6 +1439,15 @@ export function EmailComposer({
};
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
// Strip the cross-account namespace from the identity id before
// handing it to the parent - the JMAP server only knows the raw
// 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?.({
to: outgoing.to,
cc: outgoing.cc,
@@ -1331,8 +1458,9 @@ export function EmailComposer({
draftId: finalDraftId || undefined,
fromEmail,
fromName,
identityId: outgoing.identityId || currentIdentity?.id,
identityId: rawId,
envelopeMailFrom,
localAccountId: identityLocalAccountId ?? undefined,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
@@ -1356,6 +1484,7 @@ export function EmailComposer({
setBcc("");
setSubject("");
setBody("");
draftIdRef.current = null;
setDraftId(null);
setSubAddressTag("");
setValidationErrors({});
@@ -1501,16 +1630,31 @@ export function EmailComposer({
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"
>
{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>
);
})}
{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
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</select>
) : (
<span className="text-sm text-foreground flex-1 truncate">
+8 -1
View File
@@ -21,6 +21,7 @@ interface EmailListItemProps {
email: Email;
selected?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
@@ -30,7 +31,7 @@ interface EmailListItemProps {
onMarkAsSpam?: () => void;
}
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
@@ -125,6 +126,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
onClick?.();
}
}}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onDoubleClick) return;
e.preventDefault();
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
+3
View File
@@ -23,6 +23,7 @@ interface EmailListProps {
emails: Email[];
selectedEmailId?: string;
onEmailSelect?: (email: Email) => void;
onEmailDoubleClick?: (email: Email) => void;
className?: string;
isLoading?: boolean;
onOpenConversation?: (thread: ThreadGroup) => void;
@@ -44,6 +45,7 @@ export function EmailList({
emails,
selectedEmailId,
onEmailSelect,
onEmailDoubleClick,
className,
isLoading = false,
onOpenConversation,
@@ -447,6 +449,7 @@ export function EmailList({
expandedEmails={threadEmailsCache.get(thread.threadId)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
onEmailSelect={(email) => onEmailSelect?.(email)}
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation}
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
+64 -18
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
@@ -65,6 +65,7 @@ import {
PenSquare,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
@@ -81,7 +82,7 @@ import { useTour } from "@/components/tour/tour-provider";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
import { isFilePreviewable } from "@/lib/file-preview";
import { isFilePreviewable, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
import { SmimeStatusBanner } from "./smime-status-banner";
import { detectSmime } from "@/lib/smime/smime-detect";
import { smimeDecrypt, SmimeKeyLockedError, normalizeCmsBytes } from "@/lib/smime/smime-decrypt";
@@ -92,7 +93,7 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginStore } from "@/stores/plugin-store";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
@@ -970,8 +971,8 @@ export function EmailViewer({
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Plugin detail sidebar state
const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
const hasDetailSidebar = detailSlots && detailSlots.length > 0;
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
const hasDetailSidebar = detailSlots.length > 0;
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
const detailSidebarWidthRef = useRef(280);
@@ -1137,9 +1138,34 @@ export function EmailViewer({
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
const contacts = useContactStore((s) => s.contacts);
const { isMobile: isMobileDevice } = useDeviceDetection();
const router = useRouter();
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
if (isMobileDevice) return; // no sidebar on mobile
if (isMobileDevice) {
// No room for a sidebar on mobile - send the user to the contacts page
// with params describing what to show. The `from=email` flag turns the
// page's mobile back button into a router.back() that returns here.
const allRecipients = [
...(email?.from || []),
...(email?.to || []),
...(email?.cc || []),
...(email?.bcc || []),
...(email?.replyTo || []),
];
const recipientName = allRecipients.find(
(r) => r.email.toLowerCase() === recipientEmail.toLowerCase()
)?.name;
const params = new URLSearchParams();
if (contact) {
params.set('contactId', contact.id);
} else {
params.set('addEmail', recipientEmail);
if (recipientName) params.set('addName', recipientName);
}
params.set('from', 'email');
router.push(`/contacts?${params.toString()}`);
return;
}
setContactSidebarEmail(recipientEmail);
};
@@ -2535,7 +2561,12 @@ export function EmailViewer({
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
// image/svg+xml, etc.) would execute as the webmail origin if opened
// top-level. Force the download path for anything not on the inert allowlist.
const opensPreview = isPreviewable
&& mailAttachmentAction === 'preview'
&& isMimeTypeSafeForInlinePreview(attachment.type);
const info: AttachmentInfo = {
name: attachment.name || '',
@@ -2787,7 +2818,9 @@ export function EmailViewer({
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
<style>
html, body { overflow: hidden; }
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
@media (max-width: 640px) { body { padding-left: 0; padding-right: 0; } }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
@@ -2863,7 +2896,7 @@ export function EmailViewer({
// window between selectedEmail changing and isLoading flipping true, so the
// quick reply / body don't flicker through a partial render.
// An empty bodyValues with no referenced parts means the email has no body
// (e.g. calendar-only invites) not "still loading".
// (e.g. calendar-only invites) - not "still loading".
const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0;
const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0));
@@ -3061,14 +3094,26 @@ export function EmailViewer({
if (!email) return;
const printSender = email.from?.[0];
const date = email.sentAt ? formatDateTime(email.sentAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
const toList = email.to?.map(r => r.name ? `${r.name} &lt;${r.email}&gt;` : r.email).join(', ') || '';
const ccList = email.cc?.map(r => r.name ? `${r.name} &lt;${r.email}&gt;` : r.email).join(', ') || '';
const formatRecipient = (r: { name?: string | null; email: string }) =>
r.name ? `${escapeHtml(r.name)} &lt;${escapeHtml(r.email)}&gt;` : escapeHtml(r.email);
const toList = email.to?.map(formatRecipient).join(', ') || '';
const ccList = email.cc?.map(formatRecipient).join(', ') || '';
const subjectText = email.subject || t('no_subject');
const senderText = printSender?.name
? `${printSender.name} <${printSender.email}>`
: printSender?.email || t('unknown_sender');
// The body was sanitized with EMAIL_IFRAME_SANITIZE_CONFIG which permits
// <style>. The print window has no iframe isolation, so re-sanitize with
// the stricter config that forbids <style> before injecting into the DOM.
const printableBody = effectiveEmailContent.isHtml
? sanitizeEmailHtml(effectiveEmailContent.html)
: effectiveEmailContent.html;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${DOMPurify.sanitize(email.subject || t('no_subject'))}</title>
<html><head><meta charset="utf-8"><title>${escapeHtml(subjectText)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 40px; color: #000; }
.header { border-bottom: 1px solid #ccc; padding-bottom: 16px; margin-bottom: 16px; }
@@ -3080,15 +3125,15 @@ export function EmailViewer({
@media print { body { margin: 20px; } }
</style></head><body>
<div class="header">
<div class="subject">${DOMPurify.sanitize(email.subject || t('no_subject'))}</div>
<div class="subject">${escapeHtml(subjectText)}</div>
<div class="meta">
<div><strong>${t('from')}:</strong> ${DOMPurify.sanitize(printSender?.name ? `${printSender.name} <${printSender.email}>` : printSender?.email || t('unknown_sender'))}</div>
${toList ? `<div><strong>${t('to')}:</strong> ${toList}</div>` : ''}
<div><strong>${escapeHtml(t('from'))}:</strong> ${escapeHtml(senderText)}</div>
${toList ? `<div><strong>${escapeHtml(t('to'))}:</strong> ${toList}</div>` : ''}
${ccList ? `<div><strong>CC:</strong> ${ccList}</div>` : ''}
${date ? `<div><strong>${t('date')}:</strong> ${DOMPurify.sanitize(date)}</div>` : ''}
${date ? `<div><strong>${escapeHtml(t('date'))}:</strong> ${escapeHtml(date)}</div>` : ''}
</div>
</div>
<div class="body">${effectiveEmailContent.html}</div>
<div class="body">${printableBody}</div>
</body></html>`);
printWindow.document.close();
printWindow.focus();
@@ -5154,14 +5199,15 @@ export function EmailViewer({
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
className="w-full border-0"
className="w-full border-0 block"
scrolling="no"
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
onLoad={handleIframeLoad}
/>
) : (
<div
className="email-content-text text-foreground"
dangerouslySetInnerHTML={{ __html: effectiveEmailContent.html }}
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }}
style={{
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
fontSize: '14px',
+6 -2
View File
@@ -39,7 +39,11 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
const phones = contact?.phones ? Object.values(contact.phones) : [];
const orgs = contact?.organizations ? Object.values(contact.organizations) : [];
const handleOpen = () => {
const handleToggle = () => {
if (isOpen) {
handleClose();
return;
}
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const popoverWidth = 300;
@@ -125,7 +129,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
<>
<button
ref={triggerRef}
onClick={handleOpen}
onClick={handleToggle}
className={cn(
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words",
className
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
@@ -453,6 +453,7 @@ function EmailCard({
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>
html, body { overflow: hidden; }
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
@@ -584,6 +585,7 @@ function EmailCard({
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
className="w-full border-0 block"
scrolling="no"
style={{ minHeight: '60px' }}
onLoad={handleIframeLoad}
/>
@@ -598,7 +600,7 @@ function EmailCard({
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
/>
)}
</div>
+8
View File
@@ -18,6 +18,7 @@ interface ThreadEmailItemProps {
selected?: boolean;
isLast?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
}
@@ -26,6 +27,7 @@ export function ThreadEmailItem({
selected,
isLast = false,
onClick,
onDoubleClick,
onContextMenu,
}: ThreadEmailItemProps) {
const t = useTranslations('email_viewer');
@@ -96,6 +98,12 @@ export function ThreadEmailItem({
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onDoubleClick) return;
e.preventDefault();
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ paddingBlock: 'var(--density-item-py)' }}
>
+18 -1
View File
@@ -25,6 +25,7 @@ interface ThreadListItemProps {
expandedEmails?: Email[];
onToggleExpand: () => void;
onEmailSelect: (email: Email) => void;
onEmailDoubleClick?: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void;
onToggleStar?: (email: Email) => void;
@@ -39,6 +40,7 @@ interface SingleEmailItemProps {
email: Email;
selected: boolean;
onClick: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
@@ -51,7 +53,7 @@ interface SingleEmailItemProps {
}
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
@@ -146,6 +148,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onDoubleClick) return;
e.preventDefault();
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
@@ -351,6 +359,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
expandedEmails,
onToggleExpand,
onEmailSelect,
onEmailDoubleClick,
onContextMenu,
onOpenConversation,
onToggleStar,
@@ -420,6 +429,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
email={latestEmail}
selected={selectedEmailId === latestEmail.id}
onClick={() => onEmailSelect(latestEmail)}
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
@@ -506,6 +516,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleHeaderClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onEmailDoubleClick) return;
e.preventDefault();
onEmailDoubleClick(latestEmail);
}}
onContextMenu={handleContextMenu}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
@@ -762,6 +778,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
selected={email.id === selectedEmailId}
isLast={index === emailsToShow.length - 1}
onClick={() => onEmailSelect(email)}
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(email) : undefined}
onContextMenu={onContextMenu}
/>
))
+216 -89
View File
@@ -11,7 +11,9 @@ import {
AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu,
} from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils";
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
@@ -21,6 +23,7 @@ import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Avatar } from "@/components/ui/avatar";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import type { FileResource } from "@/stores/file-store";
@@ -35,6 +38,13 @@ interface ClipboardState {
sourceParentId: string | null;
}
export interface AccountFolderEntry {
accountId: string;
label: string;
email: string;
avatarColor: string;
}
interface FileBrowserProps {
currentPath: string;
resources: FileResource[];
@@ -78,6 +88,13 @@ interface FileBrowserProps {
showDetails: boolean;
onToggleDetails: () => void;
detailResource: FileResource | null;
/** Pro shell only: all connected accounts surfaced as top-level folders at the root. */
accountFolders?: AccountFolderEntry[];
onSelectAccount?: (accountId: string) => void;
/** Pro shell only: when true, the root is a pure account picker - hide the file toolbar and don't render a regular listing. */
accountPickerMode?: boolean;
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
accountLabel?: string | null;
}
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
@@ -321,6 +338,10 @@ export function FileBrowser({
onToggleDetails,
detailResource,
clipboard,
accountFolders,
onSelectAccount,
accountPickerMode,
accountLabel,
}: FileBrowserProps) {
const t = useTranslations("files");
const [showNewFolder, setShowNewFolder] = useState(false);
@@ -352,6 +373,13 @@ export function FileBrowser({
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
const [dragTarget, setDragTarget] = useState<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
useEffect(() => {
@@ -442,8 +470,10 @@ export function FileBrowser({
return sorted;
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
// Build breadcrumb segments
const breadcrumbs = currentPath === '/'
// Build breadcrumb segments. In Pro mode an account is mounted "between"
// Home and the account's filesystem - surfaced as a non-clickable label
// (clicking the actual account again would be a no-op; Home detaches it).
const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = currentPath === '/'
? [{ name: t("breadcrumb_root"), path: '/' }]
: [
{ name: t("breadcrumb_root"), path: '/' },
@@ -452,14 +482,24 @@ export function FileBrowser({
path: '/' + arr.slice(0, i + 1).join('/'),
})),
];
if (accountLabel) {
breadcrumbs.splice(1, 0, { name: accountLabel, path: '', isAccount: true });
}
const handleNavigateUp = useCallback(() => {
if (currentPath === '/') return;
const segments = currentPath.split('/').filter(Boolean);
segments.pop();
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
onNavigate(parentPath, null);
}, [currentPath, onNavigate]);
// Pro shell: going up to root from a subfolder must land on the
// account's filesystem root, not detach back to the account picker.
// Home click (breadcrumb) still detaches.
if (parentPath === '/' && accountLabel) {
onNavigate('/', '__account_root__');
return;
}
onNavigate(parentPath);
}, [currentPath, onNavigate, accountLabel]);
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
if (resource.isDirectory) {
@@ -846,14 +886,27 @@ export function FileBrowser({
>
{/* Toolbar */}
<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 */}
<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) => (
<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" />}
<button
onClick={() => onNavigate(crumb.path)}
onContextMenu={(e) => handleBreadcrumbRightClick(e, crumb.path)}
onClick={() => crumb.isAccount
? onNavigate('/', '__account_root__')
: onNavigate(crumb.path)}
onContextMenu={(e) => crumb.isAccount ? undefined : handleBreadcrumbRightClick(e, crumb.path)}
className={cn(
"px-1.5 py-0.5 rounded hover:bg-muted transition-colors",
i === breadcrumbs.length - 1
@@ -902,15 +955,17 @@ export function FileBrowser({
{t("paste")} ({clipboard.names.length})
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowSearch(v => !v)}
title={t("search_placeholder")}
>
<Search className="w-4 h-4" />
</Button>
{!accountPickerMode && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowSearch(v => !v)}
title={t("search_placeholder")}
>
<Search className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -920,62 +975,66 @@ export function FileBrowser({
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <LayoutList className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", showDetails && "bg-muted")}
onClick={onToggleDetails}
title={t("details")}
>
<Info className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
onClick={() => onToggleFavorite(currentPath)}
title={t("toggle_favorite")}
>
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => fileInputRef.current?.click()}
title={t("upload")}
disabled={isUploading}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => folderInputRef.current?.click()}
title={t("upload_folder")}
disabled={isUploading}
>
<FolderUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowNewFolder(true)}
title={t("new_folder")}
>
<FolderPlus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowNewTextFile(true)}
title={t("new_text_file")}
>
<FilePlus className="w-4 h-4" />
</Button>
{!accountPickerMode && (
<>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", showDetails && "bg-muted")}
onClick={onToggleDetails}
title={t("details")}
>
<Info className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
onClick={() => onToggleFavorite(currentPath)}
title={t("toggle_favorite")}
>
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => fileInputRef.current?.click()}
title={t("upload")}
disabled={isUploading}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => folderInputRef.current?.click()}
title={t("upload_folder")}
disabled={isUploading}
>
<FolderUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowNewFolder(true)}
title={t("new_folder")}
>
<FolderPlus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowNewTextFile(true)}
title={t("new_text_file")}
>
<FilePlus className="w-4 h-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
@@ -1100,26 +1159,59 @@ export function FileBrowser({
{/* File list */}
<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) */}
{folderLayout === "sidebar" && (
<>
<FolderTreeSidebar
currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={sidebarWidth}
isResizing={isResizing}
/>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
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);
}
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }}
/>
</>
>
<FolderTreeSidebar
currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={288}
/>
</div>
) : (
<>
<FolderTreeSidebar
currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={sidebarWidth}
isResizing={isResizing}
/>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }}
/>
</>
)
)}
{/* Favorites & Recent sidebar (when layout is inline) */}
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
@@ -1198,6 +1290,41 @@ export function FileBrowser({
<SkeletonRow />
</tbody>
</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 === '/' ? (
<FileUploadArea
onUpload={async (files: File[]) => {
+1 -1
View File
@@ -130,7 +130,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
return (
<div
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"
)}
style={{ width: `${width}px` }}
+68 -16
View File
@@ -8,13 +8,38 @@ import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
// JMAP Identity/set caps signature fields at 2047 UTF-8 bytes per RFC 8621 §6.1.
const SIGNATURE_MAX_BYTES = 2047;
const utf8Encoder = new TextEncoder();
function utf8ByteLength(s: string): number {
return utf8Encoder.encode(s).length;
}
function truncateToUtf8Bytes(s: string, maxBytes: number): string {
if (utf8ByteLength(s) <= maxBytes) return s;
let lo = 0;
let hi = s.length;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if (utf8ByteLength(s.slice(0, mid)) <= maxBytes) lo = mid;
else hi = mid - 1;
}
// Don't split a surrogate pair: if we landed right after a high surrogate, back off one code unit.
if (lo > 0) {
const prev = s.charCodeAt(lo - 1);
if (prev >= 0xD800 && prev <= 0xDBFF) lo -= 1;
}
return s.slice(0, lo);
}
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
replyTo?: EmailAddress[] | null;
bcc?: EmailAddress[] | null;
textSignature?: string | null;
htmlSignature?: string | null;
}
interface IdentityFormProps {
@@ -96,14 +121,16 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
setIsSubmitting(true);
try {
// Sanitize HTML signature before sending to server
// JMAP needs explicit null to clear a field; undefined would be dropped
// from the JSON payload and leave the server-side value untouched.
const trimmedText = formData.textSignature?.trim() ?? '';
const trimmedHtml = formData.htmlSignature?.trim() ?? '';
const sanitizedData: IdentityFormData = {
...formData,
replyTo: parseEmailList(replyToInput),
bcc: parseEmailList(bccInput),
htmlSignature: formData.htmlSignature
? sanitizeSignatureHtml(formData.htmlSignature)
: undefined,
textSignature: trimmedText ? formData.textSignature : null,
htmlSignature: trimmedHtml ? sanitizeSignatureHtml(formData.htmlSignature!) : null,
replyTo: parseEmailList(replyToInput) ?? null,
bcc: parseEmailList(bccInput) ?? null,
};
await onSave(sanitizedData);
@@ -246,14 +273,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label>
<textarea
id="identity-text-sig"
maxLength={2000}
value={formData.textSignature}
onChange={(e) => setFormData({ ...formData, textSignature: e.target.value })}
value={formData.textSignature ?? ''}
onChange={(e) => setFormData({ ...formData, textSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={3}
disabled={isSubmitting}
aria-label={t('text_signature_label')}
aria-describedby="identity-text-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<SignatureByteCounter id="identity-text-sig-counter" value={formData.textSignature || ''} />
</div>
{/* HTML Signature */}
@@ -263,14 +291,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label>
<textarea
id="identity-html-sig"
maxLength={50000}
value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
value={formData.htmlSignature ?? ''}
onChange={(e) => setFormData({ ...formData, htmlSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={5}
disabled={isSubmitting}
aria-label={t('html_signature_label')}
aria-describedby="identity-html-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<SignatureByteCounter id="identity-html-sig-counter" value={formData.htmlSignature || ''} />
{formData.htmlSignature && (
<div className="mt-2 p-2 border rounded bg-muted">
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
@@ -304,3 +333,26 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</form>
);
}
function SignatureByteCounter({ id, value }: { id: string; value: string }) {
const t = useTranslations('identities.form');
const bytes = utf8ByteLength(value);
const atLimit = bytes >= SIGNATURE_MAX_BYTES;
const nearLimit = !atLimit && bytes >= Math.floor(SIGNATURE_MAX_BYTES * 0.9);
const tone = atLimit
? 'text-destructive'
: nearLimit
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground';
return (
<p
id={id}
className={`text-xs mt-1 tabular-nums ${tone}`}
role="status"
aria-live="polite"
>
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
{atLimit && <span className="ml-1">{t('signature_byte_limit_reached')}</span>}
</p>
);
}

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