feat: add plugin hooks for compose, attachments, search, lifecycle, and routing
This commit is contained in:
+130
-3
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
import { EmailList } from "@/components/email/email-list";
|
import { EmailList } from "@/components/email/email-list";
|
||||||
@@ -58,6 +59,26 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { usePluginStore } from "@/stores/plugin-store";
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
import { useThemeStore } from "@/stores/theme-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() {
|
export default function Home() {
|
||||||
@@ -115,6 +136,88 @@ export default function Home() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [isRateLimited, rateLimitUntil]);
|
}, [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
|
// Mobile/tablet responsive hooks
|
||||||
const { isMobile, isTablet } = useDeviceDetection();
|
const { isMobile, isTablet } = useDeviceDetection();
|
||||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
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 || "");
|
setComposerDraftText(draftText || "");
|
||||||
setComposerMode('reply');
|
setComposerMode('reply');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -894,13 +1005,29 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
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');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
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');
|
setComposerMode('forward');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||||
|
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||||
|
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -242,6 +244,31 @@ export function EventModal({
|
|||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
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
|
// Report live preview to parent for grid outline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onPreviewChange || isEdit) return;
|
if (!onPreviewChange || isEdit) return;
|
||||||
@@ -923,6 +950,26 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{calendars.length > 1 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
|
<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 { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
@@ -446,10 +448,13 @@ export function EmailComposer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
autocompleteTimeoutRef.current = setTimeout(() => {
|
autocompleteTimeoutRef.current = setTimeout(async () => {
|
||||||
const results = getAutocomplete(lastPart);
|
const localResults = getAutocomplete(lastPart);
|
||||||
setAutocompleteResults(results);
|
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
|
||||||
setActiveAutoField(results.length > 0 ? field : null);
|
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);
|
setAutoSelectedIndex(-1);
|
||||||
}, 200);
|
}, 200);
|
||||||
}, [getAutocomplete]);
|
}, [getAutocomplete]);
|
||||||
@@ -559,6 +564,19 @@ export function EmailComposer({
|
|||||||
const addFiles = useCallback(async (files: File[]) => {
|
const addFiles = useCallback(async (files: File[]) => {
|
||||||
if (!client || files.length === 0) return;
|
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 newAttachments: ComposerAttachment[] = files.map(file => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
return {
|
return {
|
||||||
@@ -587,6 +605,12 @@ export function EmailComposer({
|
|||||||
: att
|
: att
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
emailHooks.onAfterAttachmentUpload.emit({
|
||||||
|
name: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
blobId,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller?.signal.aborted) continue;
|
if (controller?.signal.aborted) continue;
|
||||||
debug.error(`Failed to upload ${file.name}:`, error);
|
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)
|
// Set new timeout for auto-save (2 seconds after last change)
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
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();
|
saveDraft();
|
||||||
}, 2000);
|
}, 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 }));
|
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
||||||
uploadedAttachments.push(...inlineAttachments);
|
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,
|
to: toAddresses,
|
||||||
cc: ccAddresses,
|
cc: ccAddresses,
|
||||||
bcc: bccAddresses,
|
bcc: bccAddresses,
|
||||||
subject,
|
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,
|
draftId: finalDraftId || undefined,
|
||||||
fromEmail,
|
fromEmail,
|
||||||
fromName: currentIdentity?.name || undefined,
|
fromName: currentIdentity?.name || undefined,
|
||||||
identityId: currentIdentity?.id,
|
identityId: outgoing.identityId || currentIdentity?.id,
|
||||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
inReplyTo: threadingHeaders?.inReplyTo,
|
inReplyTo: threadingHeaders?.inReplyTo,
|
||||||
references: threadingHeaders?.references,
|
references: threadingHeaders?.references,
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ import type { TnefAttachment } from "@/lib/tnef";
|
|||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { usePluginStore } from "@/stores/plugin-store";
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
|
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
||||||
|
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||||
|
|
||||||
interface EmailViewerProps {
|
interface EmailViewerProps {
|
||||||
email: Email | null;
|
email: Email | null;
|
||||||
@@ -2474,11 +2476,20 @@ export function EmailViewer({
|
|||||||
return emailContent;
|
return emailContent;
|
||||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
}, [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 isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
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) {
|
if (attachment.blobId && onDownloadAttachment) {
|
||||||
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
|
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2493,8 +2504,10 @@ export function EmailViewer({
|
|||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
if (opensPreview) {
|
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 {
|
} else {
|
||||||
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = objectUrl;
|
anchor.href = objectUrl;
|
||||||
anchor.download = attachment.name || 'download';
|
anchor.download = attachment.name || 'download';
|
||||||
@@ -2521,8 +2534,10 @@ export function EmailViewer({
|
|||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
if (opensPreview) {
|
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 {
|
} else {
|
||||||
|
emailHooks.onAttachmentDownload.emit(info);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = objectUrl;
|
anchor.href = objectUrl;
|
||||||
anchor.download = attachment.name || 'download';
|
anchor.download = attachment.name || 'download';
|
||||||
@@ -2532,9 +2547,17 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
}, [mailAttachmentAction, onDownloadAttachment]);
|
}, [mailAttachmentAction, onDownloadAttachment, email?.id]);
|
||||||
|
|
||||||
const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => {
|
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) {
|
if (attachment.blobId && onDownloadAttachment) {
|
||||||
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
|
onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type, true);
|
||||||
return;
|
return;
|
||||||
@@ -2570,7 +2593,7 @@ export function EmailViewer({
|
|||||||
anchor.click();
|
anchor.click();
|
||||||
anchor.remove();
|
anchor.remove();
|
||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
}, [onDownloadAttachment]);
|
}, [onDownloadAttachment, email?.id]);
|
||||||
|
|
||||||
// Pre-fetch object URLs for image attachments so their actual contents can be
|
// 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.
|
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
|
||||||
@@ -2786,6 +2809,27 @@ export function EmailViewer({
|
|||||||
a.setAttribute('rel', 'noopener noreferrer');
|
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
|
// Dark mode: re-invert elements with stylesheet-defined background images
|
||||||
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
||||||
if (isDark && !emailHasNativeDarkMode) {
|
if (isDark && !emailHasNativeDarkMode) {
|
||||||
@@ -2813,7 +2857,7 @@ export function EmailViewer({
|
|||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restrictions - iframe will still display content
|
// Cross-origin restrictions - iframe will still display content
|
||||||
}
|
}
|
||||||
}, [isDark, emailHasNativeDarkMode]);
|
}, [isDark, emailHasNativeDarkMode, email?.id]);
|
||||||
|
|
||||||
// Export email as .eml file
|
// Export email as .eml file
|
||||||
const handleExportEmail = async () => {
|
const handleExportEmail = async () => {
|
||||||
|
|||||||
+54
-2
@@ -23,7 +23,7 @@ import {
|
|||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||||
sidebarAppHooks, avatarHooks, renderHooks,
|
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
|
||||||
} from './plugin-hooks';
|
} from './plugin-hooks';
|
||||||
import { createPluginI18n } from './plugin-i18n';
|
import { createPluginI18n } from './plugin-i18n';
|
||||||
import { toast as appToast } from '@/stores/toast-store';
|
import { toast as appToast } from '@/stores/toast-store';
|
||||||
@@ -187,6 +187,22 @@ export interface PluginHooksAPI {
|
|||||||
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
|
/** Intercept - receives MailtoContext, return false to prevent the system mail client */
|
||||||
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
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
|
// Calendar
|
||||||
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -204,6 +220,8 @@ export interface PluginHooksAPI {
|
|||||||
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarAlertAcknowledge: (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
|
// Calendar Form
|
||||||
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -220,6 +238,8 @@ export interface PluginHooksAPI {
|
|||||||
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onContactMove: (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
|
// Files
|
||||||
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -297,6 +317,10 @@ export interface PluginHooksAPI {
|
|||||||
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onMobileBack: (handler: () => void) => Disposable;
|
onMobileBack: (handler: () => void) => Disposable;
|
||||||
onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => 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
|
// Theme
|
||||||
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -305,6 +329,8 @@ export interface PluginHooksAPI {
|
|||||||
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBrowserNotification: (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
|
// Drag & Drop
|
||||||
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -320,6 +346,12 @@ export interface PluginHooksAPI {
|
|||||||
onBeforeUnload: (handler: () => void) => Disposable;
|
onBeforeUnload: (handler: () => void) => Disposable;
|
||||||
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onInterval: (handler: () => void, intervalMs: number) => 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
|
// Account Security
|
||||||
onPasswordChange: (handler: () => void) => Disposable;
|
onPasswordChange: (handler: () => void) => Disposable;
|
||||||
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -335,6 +367,11 @@ export interface PluginHooksAPI {
|
|||||||
// Render - transform hook for email list row badges
|
// Render - transform hook for email list row badges
|
||||||
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
||||||
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
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 ----------------------------
|
// --- Permission mapping for hooks ----------------------------
|
||||||
@@ -350,7 +387,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
||||||
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
||||||
onMailtoIntercept: 'email:read', onEmailListItemRender: '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',
|
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
|
||||||
|
onTransformOutgoingEmail: 'email:send',
|
||||||
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
||||||
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
||||||
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
|
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
|
||||||
@@ -362,6 +405,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
|
||||||
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
|
||||||
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
|
||||||
|
onCheckEventConflicts: 'calendar:read',
|
||||||
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
|
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
|
||||||
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
|
||||||
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
|
||||||
@@ -370,6 +414,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
|
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
|
||||||
// Contacts
|
// Contacts
|
||||||
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
|
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
|
||||||
|
onProvideRecipientSuggestions: 'contacts:read',
|
||||||
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
|
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
|
||||||
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
|
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
|
||||||
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
|
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
|
||||||
@@ -419,12 +464,13 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
|
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
|
||||||
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
|
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
|
||||||
onMobileViewSwitch: 'ui:observe',
|
onMobileViewSwitch: 'ui:observe',
|
||||||
|
onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe',
|
||||||
// Theme
|
// Theme
|
||||||
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
|
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
|
||||||
onLocaleChange: 'ui:observe',
|
onLocaleChange: 'ui:observe',
|
||||||
// Toast
|
// Toast
|
||||||
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
|
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
|
||||||
onBrowserNotification: 'ui:observe',
|
onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe',
|
||||||
// Drag & Drop
|
// Drag & Drop
|
||||||
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
|
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
|
||||||
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
|
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
|
||||||
@@ -435,6 +481,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
|
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
|
||||||
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
|
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
|
||||||
onInterval: 'app:lifecycle',
|
onInterval: 'app:lifecycle',
|
||||||
|
onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle',
|
||||||
|
onOnline: 'app:lifecycle', onOffline: 'app:lifecycle',
|
||||||
// Account Security
|
// Account Security
|
||||||
onPasswordChange: 'security:read', onTotpChange: 'security:read',
|
onPasswordChange: 'security:read', onTotpChange: 'security:read',
|
||||||
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
|
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
|
||||||
@@ -444,6 +492,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onSidebarAppChange: 'ui:observe',
|
onSidebarAppChange: 'ui:observe',
|
||||||
// Avatar
|
// Avatar
|
||||||
onAvatarResolve: 'email:read',
|
onAvatarResolve: 'email:read',
|
||||||
|
// Router
|
||||||
|
onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map hook names → actual HookBus instances
|
// 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)),
|
...Object.fromEntries(Object.entries(avatarHooks)),
|
||||||
// Render
|
// Render
|
||||||
...Object.fromEntries(Object.entries(renderHooks)),
|
...Object.fromEntries(Object.entries(renderHooks)),
|
||||||
|
// Router
|
||||||
|
...Object.fromEntries(Object.entries(routerHooks)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Slot registration bridge --------------------------------
|
// --- Slot registration bridge --------------------------------
|
||||||
|
|||||||
+70
-1
@@ -207,6 +207,38 @@ export const emailHooks = {
|
|||||||
// Intercept hook - fired when a mailto: link is clicked.
|
// Intercept hook - fired when a mailto: link is clicked.
|
||||||
// Return false to prevent the browser from opening the system mail client.
|
// Return false to prevent the browser from opening the system mail client.
|
||||||
onMailtoIntercept: new HookBus(),
|
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
|
// §7.2 Calendar Hooks
|
||||||
@@ -227,6 +259,10 @@ export const calendarHooks = {
|
|||||||
onICalSubscriptionChange: new HookBus(),
|
onICalSubscriptionChange: new HookBus(),
|
||||||
onCalendarAlert: new HookBus(),
|
onCalendarAlert: new HookBus(),
|
||||||
onCalendarAlertAcknowledge: 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)
|
// §7.2b Calendar Form Hooks (UI integration)
|
||||||
@@ -249,6 +285,10 @@ export const contactHooks = {
|
|||||||
onContactGroupChange: new HookBus(),
|
onContactGroupChange: new HookBus(),
|
||||||
onContactGroupMemberChange: new HookBus(),
|
onContactGroupMemberChange: new HookBus(),
|
||||||
onContactMove: 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
|
// §7.4 File Hooks
|
||||||
@@ -358,6 +398,14 @@ export const uiHooks = {
|
|||||||
onColumnResize: new HookBus(),
|
onColumnResize: new HookBus(),
|
||||||
onMobileBack: new HookBus(),
|
onMobileBack: new HookBus(),
|
||||||
onMobileViewSwitch: 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
|
// §7.14 Theme Hooks
|
||||||
@@ -384,6 +432,10 @@ export const toastHooks = {
|
|||||||
onToastShow: new HookBus(),
|
onToastShow: new HookBus(),
|
||||||
onToastDismiss: new HookBus(),
|
onToastDismiss: new HookBus(),
|
||||||
onBrowserNotification: 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
|
// §7.16 Drag & Drop Hooks
|
||||||
@@ -408,6 +460,14 @@ export const appLifecycleHooks = {
|
|||||||
onBeforeUnload: new HookBus(),
|
onBeforeUnload: new HookBus(),
|
||||||
onAppError: new HookBus(),
|
onAppError: new HookBus(),
|
||||||
onInterval: 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
|
// §7.19 Account Security Hooks
|
||||||
@@ -433,6 +493,15 @@ export const avatarHooks = {
|
|||||||
onAvatarResolve: new HookBus(),
|
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
|
// §7.22 Render Hooks
|
||||||
export const renderHooks = {
|
export const renderHooks = {
|
||||||
// Transform hook - runs for each visible email list row.
|
// Transform hook - runs for each visible email list row.
|
||||||
@@ -451,7 +520,7 @@ const allHookGroups = [
|
|||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
||||||
avatarHooks, renderHooks,
|
avatarHooks, renderHooks, routerHooks,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function removeAllPluginHooks(pluginId: string): void {
|
export function removeAllPluginHooks(pluginId: string): void {
|
||||||
|
|||||||
@@ -522,6 +522,137 @@ export interface MailtoContext {
|
|||||||
body?: string;
|
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 ─────────────────────────────────────────
|
// ─── Plugin i18n API ─────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -494,6 +494,8 @@
|
|||||||
"send": "Send",
|
"send": "Send",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"attach": "Attach",
|
"attach": "Attach",
|
||||||
|
"attach_photos": "Photos & Videos",
|
||||||
|
"attach_files": "Files",
|
||||||
"discard": "Discard",
|
"discard": "Discard",
|
||||||
"discard_draft_title": "Discard draft?",
|
"discard_draft_title": "Discard draft?",
|
||||||
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this 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) {
|
async function handleNotificationClick(event) {
|
||||||
const data = event.notification.data || {};
|
const data = event.notification.data || {};
|
||||||
|
const tag = event.notification.tag || "";
|
||||||
const targetUrl = buildClickUrl(data);
|
const targetUrl = buildClickUrl(data);
|
||||||
|
|
||||||
const allClients = await self.clients.matchAll({
|
const allClients = await self.clients.matchAll({
|
||||||
@@ -129,6 +130,15 @@ async function handleNotificationClick(event) {
|
|||||||
includeUncontrolled: true,
|
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) {
|
for (const client of allClients) {
|
||||||
// Reuse an existing tab whenever possible - users on desktop browsers
|
// Reuse an existing tab whenever possible - users on desktop browsers
|
||||||
// get annoyed when each notification opens a fresh window.
|
// 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 { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||||
import { emailHooks } from "@/lib/plugin-hooks";
|
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 { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
@@ -42,6 +43,8 @@ interface EmailStore {
|
|||||||
searchFilters: SearchFilters;
|
searchFilters: SearchFilters;
|
||||||
isAdvancedSearchOpen: boolean;
|
isAdvancedSearchOpen: boolean;
|
||||||
searchAbortController: AbortController | null;
|
searchAbortController: AbortController | null;
|
||||||
|
/** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */
|
||||||
|
externalSearchResults: ExternalSearchResult[];
|
||||||
|
|
||||||
// Unified mailbox state
|
// Unified mailbox state
|
||||||
isUnifiedView: boolean;
|
isUnifiedView: boolean;
|
||||||
@@ -216,6 +219,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||||
isAdvancedSearchOpen: false,
|
isAdvancedSearchOpen: false,
|
||||||
searchAbortController: null,
|
searchAbortController: null,
|
||||||
|
externalSearchResults: [],
|
||||||
|
|
||||||
// Unified mailbox state
|
// Unified mailbox state
|
||||||
isUnifiedView: false,
|
isUnifiedView: false,
|
||||||
@@ -971,8 +975,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Get emails per page from settings
|
// Get emails per page from settings
|
||||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||||
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
||||||
|
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: result.emails,
|
||||||
|
externalSearchResults: externals,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
@@ -982,6 +988,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
emails: [],
|
emails: [],
|
||||||
|
externalSearchResults: [],
|
||||||
hasMoreEmails: false,
|
hasMoreEmails: false,
|
||||||
totalEmails: 0
|
totalEmails: 0
|
||||||
});
|
});
|
||||||
@@ -1016,8 +1023,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
||||||
|
|
||||||
set({
|
set({
|
||||||
emails: result.emails,
|
emails: result.emails,
|
||||||
|
externalSearchResults: externals,
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
totalEmails: result.total,
|
totalEmails: result.total,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -1029,6 +1039,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
emails: [],
|
emails: [],
|
||||||
|
externalSearchResults: [],
|
||||||
hasMoreEmails: false,
|
hasMoreEmails: false,
|
||||||
totalEmails: 0,
|
totalEmails: 0,
|
||||||
searchAbortController: null,
|
searchAbortController: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user