feat: add plugin hooks for compose, attachments, search, lifecycle, and routing
This commit is contained in:
+130
-3
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user