diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index def07bca..fa929acf 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -45,6 +45,8 @@ import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, Rotate
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
+import { usePluginStore } from "@/stores/plugin-store";
+
export default function Home() {
const t = useTranslations();
@@ -266,6 +268,11 @@ export default function Home() {
});
}, [checkAuth]);
+ // Initialize plugins on mount (re-activates enabled plugins after refresh)
+ useEffect(() => {
+ usePluginStore.getState().initializePlugins();
+ }, []);
+
// Hydrate persisted column widths from localStorage
useEffect(() => {
try {
@@ -1463,12 +1470,12 @@ export default function Home() {
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
{/* Inline Composer - shown in viewer pane */}
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 75be5bd6..4a14d1a0 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -57,6 +57,7 @@ import {
MapPin,
StickyNote,
PanelRightClose,
+ PanelRightOpen,
Send,
FolderInput,
Inbox,
@@ -96,6 +97,8 @@ 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 { ResizeHandle } from "@/components/layout/resize-handle";
interface EmailViewerProps {
email: Email | null;
@@ -948,6 +951,13 @@ export function EmailViewer({
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState
([]);
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 [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
+ const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
+ const detailSidebarWidthRef = useRef(280);
+
// Ensure S/MIME key records are loaded from IndexedDB
useLayoutEffect(() => {
smimeStore.load();
@@ -4445,7 +4455,7 @@ export function EmailViewer({
-
+
{/* Email Body */}
@@ -4691,6 +4701,58 @@ export function EmailViewer({
)}
+ {/* Plugin Detail Sidebar - resizable, collapsible */}
+ {hasDetailSidebar && !isMobile && (
+ <>
+ {/* Collapse toggle when sidebar is collapsed */}
+ {detailSidebarCollapsed && (
+
+
+
+ )}
+ {!detailSidebarCollapsed && (
+ <>
+
{ detailSidebarWidthRef.current = detailSidebarWidth; }}
+ onResize={(delta) => {
+ const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
+ setDetailSidebarWidth(newWidth);
+ }}
+ onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
+ onDoubleClick={() => setDetailSidebarWidth(280)}
+ />
+
+
+
+
+
+
+ >
+ )}
+ >
+ )}
+
{/* Contact Detail Sidebar - desktop only */}
{contactSidebarEmail && !isMobileDevice && (
)}
-
-
+ {!isCollapsed &&
}
+
{/* Compose Button */}
diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts
index dbbbc378..3f1f9301 100644
--- a/lib/__tests__/plugin-store.test.ts
+++ b/lib/__tests__/plugin-store.test.ts
@@ -48,6 +48,7 @@ function resetStore() {
'email-footer': [],
'composer-toolbar': [],
'sidebar-widget': [],
+ 'email-detail-sidebar': [],
'settings-section': [],
'context-menu-email': [],
'navigation-rail-bottom': [],
diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts
index cc02b017..9831d61d 100644
--- a/lib/plugin-api.ts
+++ b/lib/plugin-api.ts
@@ -113,6 +113,7 @@ export interface PluginAPI {
registerSettingsSection: (section: SettingsSection) => Disposable;
registerComposerAction: (action: ComposerAction) => Disposable;
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
+ registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
};
@@ -556,6 +557,11 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType
>, widget.order ?? 100);
},
+ registerDetailSidebar: (widget: SidebarWidget) => {
+ requirePermission(plugin, 'ui:sidebar-widget');
+ return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType>, widget.order ?? 100);
+ },
+
registerContextMenuItem: (item: ContextMenuItem) => {
requirePermission(plugin, 'ui:context-menu');
const Component = () => {
diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts
index 98873026..9dc835b3 100644
--- a/lib/plugin-loader.ts
+++ b/lib/plugin-loader.ts
@@ -49,6 +49,9 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise {
return;
}
+ // Ensure React/ReactDOM are exposed before any plugin module evaluates
+ exposePluginExternals();
+
try {
// 1. Read bundle from IndexedDB
const code = await pluginStorage.getCode(plugin.id);
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index d679f898..05c8a6a6 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -85,6 +85,7 @@ export type SlotName =
| 'email-footer'
| 'composer-toolbar'
| 'sidebar-widget'
+ | 'email-detail-sidebar'
| 'settings-section'
| 'context-menu-email'
| 'navigation-rail-bottom';
diff --git a/proxy.ts b/proxy.ts
index bbf5c4f9..01a00312 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -9,8 +9,8 @@ export function proxy(request: NextRequest) {
const isDev = process.env.NODE_ENV === "development";
const scriptSrc = isDev
- ? `'self' 'nonce-${nonce}' 'unsafe-eval'`
- : `'self' 'nonce-${nonce}'`;
+ ? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
+ : `'self' 'nonce-${nonce}' blob:`;
const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`;
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 675e126b..d347c6c4 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -4,6 +4,7 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
+import { emailHooks } from "@/lib/plugin-hooks";
interface EmailStore {
emails: Email[];
@@ -159,7 +160,16 @@ export const useEmailStore = create((set, get) => ({
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
- selectEmail: (email) => set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }),
+ selectEmail: (email) => {
+ const prev = get().selectedEmail;
+ set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId });
+ if (prev && (!email || email.id !== prev.id)) {
+ emailHooks.onEmailClose.emitSync(prev);
+ }
+ if (email && (!prev || email.id !== prev.id)) {
+ emailHooks.onEmailOpen.emitSync(email);
+ }
+ },
selectKeyword: (keyword) => set({
selectedKeyword: keyword,
selectedEmail: null,
diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts
index 86bd4e60..16d2c5cc 100644
--- a/stores/plugin-store.ts
+++ b/stores/plugin-store.ts
@@ -19,7 +19,7 @@ import { removeAllPluginHooks } from '@/lib/plugin-hooks';
const SLOT_NAMES: SlotName[] = [
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
- 'sidebar-widget', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
+ 'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
];
function emptySlots(): Record {
@@ -136,6 +136,10 @@ export const usePluginStore = create()(
const plugin = plugins.find(p => p.id === id);
if (!plugin) return;
+ // Ensure bridges are wired before loading (may not have run initializePlugins yet)
+ setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
+ setSlotRegistrationBridge(get().registerSlot);
+
set({
plugins: plugins.map(p =>
p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p