feat: add plugin hooks for compose, attachments, search, lifecycle, and routing

This commit is contained in:
Linus Rath
2026-05-02 21:27:56 +02:00
parent 5e096240b3
commit f970fd1822
10 changed files with 565 additions and 20 deletions
+130 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { EmailList } from "@/components/email/email-list";
@@ -58,6 +59,26 @@ import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { usePluginStore } from "@/stores/plugin-store";
import { useThemeStore } from "@/stores/theme-store";
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
import type { EmailReadView } from "@/lib/plugin-types";
function emailToReadView(email: Email): EmailReadView {
return {
id: email.id,
threadId: email.threadId,
mailboxIds: Object.keys(email.mailboxIds || {}).filter(k => email.mailboxIds[k]),
from: (email.from || []).map(a => ({ name: a.name || '', email: a.email })),
to: (email.to || []).map(a => ({ name: a.name || '', email: a.email })),
cc: (email.cc || []).map(a => ({ name: a.name || '', email: a.email })),
subject: email.subject || '',
receivedAt: email.receivedAt,
isRead: !!email.keywords?.['$seen'],
isFlagged: !!email.keywords?.['$flagged'],
hasAttachment: email.hasAttachment,
preview: email.preview || '',
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
};
}
export default function Home() {
@@ -115,6 +136,88 @@ export default function Home() {
return () => clearInterval(timer);
}, [isRateLimited, rateLimitUntil]);
// Plugin hooks: window-level lifecycle + selection + service-worker messages.
// One effect because the listeners share a registration / cleanup window.
useEffect(() => {
if (typeof window === 'undefined') return;
const onFocus = () => { appLifecycleHooks.onWindowFocus.emit(); };
const onBlur = () => { appLifecycleHooks.onWindowBlur.emit(); };
const onOnline = () => { appLifecycleHooks.onOnline.emit(); };
const onOffline = () => { appLifecycleHooks.onOffline.emit(); };
let selectionTimer: ReturnType<typeof setTimeout> | null = null;
const onSelectionChange = () => {
if (selectionTimer) clearTimeout(selectionTimer);
selectionTimer = setTimeout(() => {
const sel = document.getSelection();
const text = sel?.toString() ?? '';
if (!text) return;
const anchorNode = sel?.anchorNode as Node | null;
const anchorEl = (anchorNode?.nodeType === Node.ELEMENT_NODE
? anchorNode as Element
: anchorNode?.parentElement) ?? null;
let source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other' = 'other';
let emailId: string | undefined;
if (anchorEl) {
if (anchorEl.closest('[data-plugin-source="email-body"], iframe.email-body, .email-viewer-body')) {
source = 'email-body';
const idEl = anchorEl.closest('[data-email-id]') as HTMLElement | null;
emailId = idEl?.dataset.emailId;
} else if (anchorEl.closest('[data-plugin-source="composer"], .email-composer')) {
source = 'composer';
} else if (anchorEl.closest('[data-plugin-source="task-detail"]')) {
source = 'task-detail';
} else if (anchorEl.closest('[data-plugin-source="event-detail"]')) {
source = 'event-detail';
}
}
uiHooks.onTextSelectionChange.emit({ text, source, emailId });
}, 150);
};
const onSwMessage = (e: MessageEvent) => {
const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null;
if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') {
toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data });
}
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
document.addEventListener('selectionchange', onSelectionChange);
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', onSwMessage);
}
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
document.removeEventListener('selectionchange', onSelectionChange);
if (selectionTimer) clearTimeout(selectionTimer);
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
navigator.serviceWorker.removeEventListener('message', onSwMessage);
}
};
}, []);
// Plugin hooks: route navigation. Tracks Next.js pathname transitions.
const pathname = usePathname();
const prevPathnameRef = useRef<string | null>(null);
useEffect(() => {
if (!pathname) return;
const from = prevPathnameRef.current;
if (from === pathname) return;
if (from !== null) {
routerHooks.onRouteLeave.emit({ path: from });
routerHooks.onNavigate.emit({ path: pathname, from });
}
routerHooks.onRouteEnter.emit({ path: pathname });
prevPathnameRef.current = pathname;
}, [pathname]);
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
@@ -835,7 +938,15 @@ export default function Home() {
}
};
const handleReply = (draftText?: string) => {
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReply.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'reply' as const,
});
if (!ok) return;
}
setComposerDraftText(draftText || "");
setComposerMode('reply');
setShowComposer(true);
@@ -894,13 +1005,29 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
const handleReplyAll = () => {
const handleReplyAll = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReplyAll.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'reply-all' as const,
});
if (!ok) return;
}
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
};
const handleForward = () => {
const handleForward = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeForward.intercept({
originalEmailId: selectedEmail.id,
originalEmail: emailToReadView(selectedEmail),
mode: 'forward' as const,
});
if (!ok) return;
}
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
+47
View File
@@ -22,6 +22,8 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { calendarHooks } from "@/lib/plugin-hooks";
import type { ConflictWarning } from "@/lib/plugin-types";
export interface PendingEventPreview {
start: Date;
@@ -242,6 +244,31 @@ export function EventModal({
const [sendInvitations, setSendInvitations] = useState(true);
const participantInputRef = useRef<ParticipantInputHandle>(null);
// Plugin transform: collect conflict warnings for the current event form.
// Re-runs (debounced) whenever fields that affect scheduling change.
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
useEffect(() => {
let cancelled = false;
const t = setTimeout(async () => {
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const warnings = await calendarHooks.onCheckEventConflicts.transform([] as ConflictWarning[], {
event: {
title,
description,
start: startStr,
end: endStr,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
},
});
if (!cancelled) setPluginConflictWarnings(warnings);
}, 250);
return () => { cancelled = true; clearTimeout(t); };
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
@@ -923,6 +950,26 @@ export function EventModal({
)}
</div>
{pluginConflictWarnings.length > 0 && (
<div className="space-y-1.5">
{pluginConflictWarnings.map(w => (
<div
key={w.key}
className={
w.severity === 'error'
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
: w.severity === 'info'
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
: 'text-sm rounded-md border border-yellow-500/50 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 px-3 py-2'
}
title={w.message}
>
{w.message}
</div>
))}
</div>
)}
{calendars.length > 1 && (
<div>
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
+60 -8
View File
@@ -10,6 +10,8 @@ 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 { 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 { useAccountStore } from "@/stores/account-store";
@@ -446,10 +448,13 @@ export function EmailComposer({
return;
}
autocompleteTimeoutRef.current = setTimeout(() => {
const results = getAutocomplete(lastPart);
setAutocompleteResults(results);
setActiveAutoField(results.length > 0 ? field : null);
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(lastPart);
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query: lastPart });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setActiveAutoField(merged.length > 0 ? field : null);
setAutoSelectedIndex(-1);
}, 200);
}, [getAutocomplete]);
@@ -559,6 +564,19 @@ export function EmailComposer({
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
// Let plugins veto each upload before it's queued.
const allowedFiles: File[] = [];
for (const file of files) {
const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
});
if (ok) allowedFiles.push(file);
}
if (allowedFiles.length === 0) return;
files = allowedFiles;
const newAttachments: ComposerAttachment[] = files.map(file => {
const controller = new AbortController();
return {
@@ -587,6 +605,12 @@ export function EmailComposer({
: att
)
);
emailHooks.onAfterAttachmentUpload.emit({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
blobId,
});
} catch (error) {
if (controller?.signal.aborted) continue;
debug.error(`Failed to upload ${file.name}:`, error);
@@ -782,6 +806,19 @@ export function EmailComposer({
// Set new timeout for auto-save (2 seconds after last change)
saveTimeoutRef.current = setTimeout(() => {
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
subject,
htmlBody: plainTextMode ? '' : body,
textBody: plainTextMode ? body : htmlToPlainText(body),
identityId: selectedIdentityId || '',
attachments: attachments
.filter(a => a.blobId && !a.uploading && !a.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
});
saveDraft();
}, 2000);
@@ -1065,17 +1102,32 @@ export function EmailComposer({
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
await onSend?.({
// Let plugins (signatures, link-rewriting, encryption, AI rewrite, …)
// transform the outgoing message immediately before submission.
const transformInput: OutgoingEmail = {
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
body: finalBody,
htmlBody: finalHtmlBody,
htmlBody: finalHtmlBody || '',
textBody: finalBody,
identityId: currentIdentity?.id || '',
attachments: uploadedAttachments.map(a => ({ name: a.name, type: a.type, size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
};
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
await onSend?.({
to: outgoing.to,
cc: outgoing.cc,
bcc: outgoing.bcc,
subject: outgoing.subject,
body: outgoing.textBody,
htmlBody: outgoing.htmlBody || undefined,
draftId: finalDraftId || undefined,
fromEmail,
fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id,
identityId: outgoing.identityId || currentIdentity?.id,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
+50 -6
View File
@@ -93,6 +93,8 @@ 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";
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
interface EmailViewerProps {
email: Email | null;
@@ -2474,11 +2476,20 @@ export function EmailViewer({
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
const handleEffectiveAttachmentOpen = useCallback((attachment: EffectiveAttachment) => {
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
};
if (attachment.blobId && onDownloadAttachment) {
emailHooks.onAttachmentDownload.emit(info);
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
return;
}
@@ -2493,8 +2504,10 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob);
if (opensPreview) {
window.open(objectUrl, '_blank', 'noopener,noreferrer');
const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info);
window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer');
} else {
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
@@ -2521,8 +2534,10 @@ export function EmailViewer({
const objectUrl = URL.createObjectURL(blob);
if (opensPreview) {
window.open(objectUrl, '_blank', 'noopener,noreferrer');
const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info);
window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer');
} else {
emailHooks.onAttachmentDownload.emit(info);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = attachment.name || 'download';
@@ -2532,9 +2547,17 @@ export function EmailViewer({
}
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [mailAttachmentAction, onDownloadAttachment]);
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
const info: AttachmentInfo = {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
};
emailHooks.onAttachmentDownload.emit(info);
if (attachment.blobId && onDownloadAttachment) {
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
return;
@@ -2570,7 +2593,7 @@ export function EmailViewer({
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}, [onDownloadAttachment]);
}, [onDownloadAttachment, email?.id]);
// Pre-fetch object URLs for image attachments so their actual contents can be
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
@@ -2786,6 +2809,27 @@ export function EmailViewer({
a.setAttribute('rel', 'noopener noreferrer');
});
// Plugin intercept: let plugins cancel or rewrite external links inside
// the email body before navigation happens. Bound on the iframe doc so
// it survives DOM mutations from dark-mode pass below.
const onLinkClick = async (ev: Event) => {
const targetEl = (ev.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
if (!targetEl) return;
const href = targetEl.getAttribute('href') || '';
if (!href || href.startsWith('#') || href.startsWith('mailto:')) return;
ev.preventDefault();
ev.stopPropagation();
const ctx = {
href,
target: targetEl.getAttribute('target') ?? undefined,
emailId: email?.id,
};
const ok = await uiHooks.onBeforeExternalLink.intercept(ctx);
if (!ok) return;
window.open(ctx.href, '_blank', 'noopener,noreferrer');
};
doc.addEventListener('click', onLinkClick, true);
// Dark mode: re-invert elements with stylesheet-defined background images
// (CSS attribute selectors only catch inline styles, not <style> block rules)
if (isDark && !emailHasNativeDarkMode) {
@@ -2813,7 +2857,7 @@ export function EmailViewer({
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, [isDark, emailHasNativeDarkMode]);
}, [isDark, emailHasNativeDarkMode, email?.id]);
// Export email as .eml file
const handleExportEmail = async () => {
+54 -2
View File
@@ -23,7 +23,7 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, renderHooks,
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
} from './plugin-hooks';
import { createPluginI18n } from './plugin-i18n';
import { toast as appToast } from '@/stores/toast-store';
@@ -187,6 +187,22 @@ export interface PluginHooksAPI {
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Transform - receives the OutgoingEmail and returns a (possibly modified) copy */
onTransformOutgoingEmail: (handler: (email: import('./plugin-types').OutgoingEmail) => import('./plugin-types').OutgoingEmail | void | Promise<import('./plugin-types').OutgoingEmail | void>) => Disposable;
/** Intercept - receives ReplyContext, return false to cancel */
onBeforeReply: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
onBeforeReplyAll: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
onBeforeForward: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Intercept - receives AttachmentInfo, return false to refuse the upload */
onBeforeAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => boolean | void | Promise<boolean | void>) => Disposable;
onAfterAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
onAttachmentDownload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable;
/** Transform - receives AttachmentPreview, may return a modified preview */
onAttachmentPreview: (handler: (preview: import('./plugin-types').AttachmentPreview, info: import('./plugin-types').AttachmentInfo) => import('./plugin-types').AttachmentPreview | void | Promise<import('./plugin-types').AttachmentPreview | void>) => Disposable;
/** Transform - receives ExternalSearchResult[] and returns an extended array */
onProvideSearchResults: (handler: (results: import('./plugin-types').ExternalSearchResult[], ctx: { query: string; filters: import('./plugin-types').SearchFilters }) => import('./plugin-types').ExternalSearchResult[] | void | Promise<import('./plugin-types').ExternalSearchResult[] | void>) => Disposable;
/** Observer - debounced snapshot of the composer draft */
onDraftChange: (handler: (draft: import('./plugin-types').DraftView) => void) => Disposable;
// Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -204,6 +220,8 @@ export interface PluginHooksAPI {
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Transform - receives ConflictWarning[] and returns an extended array */
onCheckEventConflicts: (handler: (warnings: import('./plugin-types').ConflictWarning[], ctx: { event: import('./plugin-types').CalendarEventFormView }) => import('./plugin-types').ConflictWarning[] | void | Promise<import('./plugin-types').ConflictWarning[] | void>) => Disposable;
// Calendar Form
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -220,6 +238,8 @@ export interface PluginHooksAPI {
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Transform - receives RecipientSuggestion[] and returns an extended array */
onProvideRecipientSuggestions: (handler: (suggestions: import('./plugin-types').RecipientSuggestion[], ctx: { query: string }) => import('./plugin-types').RecipientSuggestion[] | void | Promise<import('./plugin-types').RecipientSuggestion[] | void>) => Disposable;
// Files
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -297,6 +317,10 @@ export interface PluginHooksAPI {
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
onMobileBack: (handler: () => void) => Disposable;
onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Intercept - receives ExternalLinkContext, return false to cancel navigation */
onBeforeExternalLink: (handler: (ctx: import('./plugin-types').ExternalLinkContext) => boolean | void | Promise<boolean | void>) => Disposable;
/** Observer - debounced text-selection change */
onTextSelectionChange: (handler: (ctx: import('./plugin-types').SelectionContext) => void) => Disposable;
// Theme
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -305,6 +329,8 @@ export interface PluginHooksAPI {
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable;
/** Observer fired when an OS-level notification is clicked */
onNotificationClick: (handler: (ctx: { tag: string; data?: unknown }) => void) => Disposable;
// Drag & Drop
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -320,6 +346,12 @@ export interface PluginHooksAPI {
onBeforeUnload: (handler: () => void) => Disposable;
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
onInterval: (handler: () => void, intervalMs: number) => Disposable;
/** Observer - browser window focus / blur */
onWindowFocus: (handler: () => void) => Disposable;
onWindowBlur: (handler: () => void) => Disposable;
/** Observer - network connectivity transitions */
onOnline: (handler: () => void) => Disposable;
onOffline: (handler: () => void) => Disposable;
// Account Security
onPasswordChange: (handler: () => void) => Disposable;
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -335,6 +367,11 @@ export interface PluginHooksAPI {
// Render - transform hook for email list row badges
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
// Router
/** Observer - fired on every in-app navigation. RouteContext.from holds the previous path. */
onNavigate: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
onRouteEnter: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
onRouteLeave: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable;
}
// --- Permission mapping for hooks ----------------------------
@@ -350,7 +387,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
onBeforeReply: 'email:read', onBeforeReplyAll: 'email:read',
onBeforeForward: 'email:read', onAttachmentDownload: 'email:read',
onAttachmentPreview: 'email:read', onProvideSearchResults: 'email:read',
onDraftChange: 'email:read',
onBeforeAttachmentUpload: 'email:write', onAfterAttachmentUpload: 'email:write',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onTransformOutgoingEmail: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
@@ -362,6 +405,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
onCheckEventConflicts: 'calendar:read',
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
@@ -370,6 +414,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
// Contacts
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
onProvideRecipientSuggestions: 'contacts:read',
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
@@ -419,12 +464,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
onMobileViewSwitch: 'ui:observe',
onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe',
// Theme
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
onLocaleChange: 'ui:observe',
// Toast
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
onBrowserNotification: 'ui:observe',
onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe',
// Drag & Drop
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
@@ -435,6 +481,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
onInterval: 'app:lifecycle',
onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle',
onOnline: 'app:lifecycle', onOffline: 'app:lifecycle',
// Account Security
onPasswordChange: 'security:read', onTotpChange: 'security:read',
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
@@ -444,6 +492,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onSidebarAppChange: 'ui:observe',
// Avatar
onAvatarResolve: 'email:read',
// Router
onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe',
};
// Map hook names → actual HookBus instances
@@ -494,6 +544,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(avatarHooks)),
// Render
...Object.fromEntries(Object.entries(renderHooks)),
// Router
...Object.fromEntries(Object.entries(routerHooks)),
};
// --- Slot registration bridge --------------------------------
+70 -1
View File
@@ -207,6 +207,38 @@ export const emailHooks = {
// Intercept hook - fired when a mailto: link is clicked.
// Return false to prevent the browser from opening the system mail client.
onMailtoIntercept: new HookBus(),
// Transform hook - fires after onBeforeEmailSend has not cancelled,
// immediately before the message is handed to the JMAP submission. Handlers
// receive an OutgoingEmail and return a modified copy (or undefined to pass
// through). Use to inject signatures, scrub tracking pixels from forwarded
// bodies, encrypt content, or rewrite links.
onTransformOutgoingEmail: new HookBus(),
// Intercept hooks fired when the user clicks Reply / Reply-All / Forward.
// Handler receives a ReplyContext; return false to cancel.
onBeforeReply: new HookBus(),
onBeforeReplyAll: new HookBus(),
onBeforeForward: new HookBus(),
// Intercept hook fired before a file is added to the composer as an
// attachment. Handler receives AttachmentInfo (size/type/name only - the
// raw file is not exposed). Return false to refuse the upload.
onBeforeAttachmentUpload: new HookBus(),
// Observer fired after an attachment has been uploaded and its blobId is
// available. Handler receives AttachmentInfo with `blobId` populated.
onAfterAttachmentUpload: new HookBus(),
// Observer fired when the user downloads an attachment from a message.
onAttachmentDownload: new HookBus(),
// Transform hook - lets plugins replace the preview URL or supply a custom
// renderer for an attachment. Initial value: AttachmentPreview, second
// argument: AttachmentInfo.
onAttachmentPreview: new HookBus(),
// Transform hook - lets plugins contribute additional results to the global
// search panel. Initial value: ExternalSearchResult[]. Second argument:
// { query: string, filters: SearchFilters }.
onProvideSearchResults: new HookBus(),
// Observer fired (debounced) when the composer draft body, subject, or
// recipients change. Handler receives a DraftView snapshot. Use for AI
// assistants, grammar checkers, etc.
onDraftChange: new HookBus(),
};
// §7.2 Calendar Hooks
@@ -227,6 +259,10 @@ export const calendarHooks = {
onICalSubscriptionChange: new HookBus(),
onCalendarAlert: new HookBus(),
onCalendarAlertAcknowledge: new HookBus(),
// Transform hook - fires when the event form is open and start/end change.
// Initial value: ConflictWarning[], second argument: { event: CalendarEventFormView }.
// Plugins return an extended array; the form renders each warning inline.
onCheckEventConflicts: new HookBus(),
};
// §7.2b Calendar Form Hooks (UI integration)
@@ -249,6 +285,10 @@ export const contactHooks = {
onContactGroupChange: new HookBus(),
onContactGroupMemberChange: new HookBus(),
onContactMove: new HookBus(),
// Transform hook - lets plugins contribute extra recipient suggestions to
// the composer's autocomplete. Initial value: RecipientSuggestion[],
// second argument: { query: string }.
onProvideRecipientSuggestions: new HookBus(),
};
// §7.4 File Hooks
@@ -358,6 +398,14 @@ export const uiHooks = {
onColumnResize: new HookBus(),
onMobileBack: new HookBus(),
onMobileViewSwitch: new HookBus(),
// Intercept hook - fires when the user clicks an external link inside the
// app (typically inside an email body iframe). Handler receives
// ExternalLinkContext; return false to cancel the navigation. Mutate
// `href` in place to rewrite (e.g. strip UTM params, route via a proxy).
onBeforeExternalLink: new HookBus(),
// Observer (debounced) fired when the user changes the active text
// selection inside an app surface. Receives SelectionContext.
onTextSelectionChange: new HookBus(),
};
// §7.14 Theme Hooks
@@ -384,6 +432,10 @@ export const toastHooks = {
onToastShow: new HookBus(),
onToastDismiss: new HookBus(),
onBrowserNotification: new HookBus(),
// Observer fired when the user clicks an OS-level browser notification
// dispatched by the host. Handler receives { tag: string, data?: unknown }
// matching the original notification options.
onNotificationClick: new HookBus(),
};
// §7.16 Drag & Drop Hooks
@@ -408,6 +460,14 @@ export const appLifecycleHooks = {
onBeforeUnload: new HookBus(),
onAppError: new HookBus(),
onInterval: new HookBus(),
// Observer fired when the browser window receives focus / blur. Useful for
// refresh-on-focus behaviour (re-poll, recheck staleness, pause timers).
onWindowFocus: new HookBus(),
onWindowBlur: new HookBus(),
// Observer fired when network connectivity transitions. Mirrors the
// navigator online / offline events.
onOnline: new HookBus(),
onOffline: new HookBus(),
};
// §7.19 Account Security Hooks
@@ -433,6 +493,15 @@ export const avatarHooks = {
onAvatarResolve: new HookBus(),
};
// §7.23 Router Hooks
// Observers fired by the app router. Handlers receive a RouteContext; on
// onNavigate the previous path is exposed via `from`.
export const routerHooks = {
onNavigate: new HookBus(),
onRouteEnter: new HookBus(),
onRouteLeave: new HookBus(),
};
// §7.22 Render Hooks
export const renderHooks = {
// Transform hook - runs for each visible email list row.
@@ -451,7 +520,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks, renderHooks,
avatarHooks, renderHooks, routerHooks,
];
export function removeAllPluginHooks(pluginId: string): void {
+131
View File
@@ -522,6 +522,137 @@ export interface MailtoContext {
body?: string;
}
/**
* Passed to onTransformOutgoingEmail handlers as a transform value.
* Handlers receive the email about to be sent and return a (possibly mutated)
* copy. Use to inject signatures, rewrite links, strip tracking pixels from
* forwards, encrypt the body, etc. Return undefined to pass through unchanged.
*/
export interface OutgoingEmail {
to: string[];
cc: string[];
bcc: string[];
subject: string;
htmlBody: string;
textBody: string;
identityId: string;
attachments: { name: string; type: string; size: number }[];
/** Original message id when this is a reply or forward */
inReplyTo?: string;
/** Free-form custom headers added by the composer or earlier handlers */
headers?: Record<string, string>;
}
/**
* Passed to onBeforeReply / onBeforeReplyAll / onBeforeForward intercept hooks.
* Return false to cancel the operation before the composer opens.
*/
export interface ReplyContext {
originalEmailId: string;
originalEmail: EmailReadView;
mode: 'reply' | 'reply-all' | 'forward';
}
/**
* Describes an attachment crossing an attachment hook (upload, download, preview).
*/
export interface AttachmentInfo {
name: string;
type: string;
size: number;
/** JMAP blob id, when known (download / preview / after-upload) */
blobId?: string;
/** The email this attachment belongs to (download / preview) */
emailId?: string;
}
/**
* Initial value passed to the onAttachmentPreview transform hook. A handler
* may return a different `previewUrl` (e.g. a proxied/sanitised URL) or a
* React component descriptor identified by `customRenderer`. Return undefined
* to pass through.
*/
export interface AttachmentPreview {
previewUrl?: string;
/** Optional plugin-supplied renderer key. The host resolves the renderer. */
customRenderer?: string;
}
/**
* Passed to onBeforeExternalLink intercept handlers when the user clicks a
* link that would navigate away from the app (typically inside an email body).
* Return false to cancel the navigation. Mutate `href` to rewrite it.
*/
export interface ExternalLinkContext {
href: string;
/** Anchor target ('_blank', '_self', etc.) when set */
target?: string;
/** Email currently in view, when the click came from an email body */
emailId?: string;
}
/**
* Passed to onTextSelectionChange observer when the user selects text inside
* the app. Source identifies which surface produced the selection so plugins
* can scope themselves (e.g. translate-on-select only inside emails).
*/
export interface SelectionContext {
text: string;
source: 'email-body' | 'composer' | 'task-detail' | 'event-detail' | 'other';
emailId?: string;
}
/**
* Returned by onCheckEventConflicts transform handlers. The form UI renders
* each warning as an inline notice next to the event time fields.
*/
export interface ConflictWarning {
/** Stable unique key per warning, used as React key */
key: string;
/** Short message — e.g. "Conflicts with: Team Standup" */
message: string;
severity?: 'info' | 'warning' | 'error';
}
/**
* Returned by onProvideSearchResults transform handlers. Plugins extend the
* initial array with their own results (CRM hits, Slack messages, etc.).
* The host renders these in a grouped section below native email results.
*/
export interface ExternalSearchResult {
/** Stable unique key */
key: string;
title: string;
snippet: string;
/** Plugin-handled action when the result row is clicked */
onClick: () => void;
/** Optional source label, e.g. "Slack", "Notion" */
source?: string;
}
/**
* Returned by onProvideRecipientSuggestions transform handlers. Lets plugins
* contribute non-contact suggestions (Slack handles, GitHub usernames, etc.)
* to the recipient autocomplete in the composer.
*/
export interface RecipientSuggestion {
name: string;
email: string;
/** Optional source label rendered as a small tag */
source?: string;
avatarUrl?: string;
}
/**
* Passed to router hooks (onNavigate, onRouteEnter, onRouteLeave).
* Paths are app-internal, e.g. "/mail/inbox", "/calendar".
*/
export interface RouteContext {
path: string;
/** Previous path (only on onNavigate) */
from?: string;
}
// ─── Plugin i18n API ─────────────────────────────────────────
/**
+2
View File
@@ -494,6 +494,8 @@
"send": "Send",
"cancel": "Cancel",
"attach": "Attach",
"attach_photos": "Photos & Videos",
"attach_files": "Files",
"discard": "Discard",
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
+10
View File
@@ -122,6 +122,7 @@ async function handlePush(event) {
async function handleNotificationClick(event) {
const data = event.notification.data || {};
const tag = event.notification.tag || "";
const targetUrl = buildClickUrl(data);
const allClients = await self.clients.matchAll({
@@ -129,6 +130,15 @@ async function handleNotificationClick(event) {
includeUncontrolled: true,
});
// Notify any in-app clients so plugins listening on toastHooks.onNotificationClick fire.
for (const client of allClients) {
try {
client.postMessage({ kind: "notificationclick", tag, data });
} catch (_) {
// Closed or detached client - ignore.
}
}
for (const client of allClients) {
// Reuse an existing tab whenever possible - users on desktop browsers
// get annoyed when each notification opens a fresh window.
+11
View File
@@ -6,6 +6,7 @@ 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";
import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
@@ -42,6 +43,8 @@ interface EmailStore {
searchFilters: SearchFilters;
isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null;
/** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */
externalSearchResults: ExternalSearchResult[];
// Unified mailbox state
isUnifiedView: boolean;
@@ -216,6 +219,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
searchAbortController: null,
externalSearchResults: [],
// Unified mailbox state
isUnifiedView: false,
@@ -971,8 +975,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
@@ -982,6 +988,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
externalSearchResults: [],
hasMoreEmails: false,
totalEmails: 0
});
@@ -1016,8 +1023,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
@@ -1029,6 +1039,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
externalSearchResults: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: null,