diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 9f4bf61a..d4c90318 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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 | 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(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'); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 7f369a33..d8f1bb51 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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(null); + // Plugin transform: collect conflict warnings for the current event form. + // Re-runs (debounced) whenever fields that affect scheduling change. + const [pluginConflictWarnings, setPluginConflictWarnings] = useState([]); + 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({ )} + {pluginConflictWarnings.length > 0 && ( +
+ {pluginConflictWarnings.map(w => ( +
+ {w.message} +
+ ))} +
+ )} + {calendars.length > 1 && (
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 350a182c..14782c9e 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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, diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ddbdf991..81c7ed42 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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