diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index a76c8fdd..905549b3 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -13,6 +13,7 @@ import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; +import { generateEmailSource } from "@/lib/email-source"; import { Reply, ReplyAll, @@ -1053,6 +1054,9 @@ export function EmailViewer({ // sessions so the panel reopens the way the user last left it. const detailSlots = usePluginSlotOffers('email-detail-sidebar'); const hasDetailSidebar = detailSlots.length > 0; + // Whether any plugin offers a "more details" section, so we only render the + // bottom plugin category wrapper when something will fill it. + const hasDetailsSlotOffers = usePluginSlotOffers('email-details-section').length > 0; const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(() => { if (typeof window === 'undefined') return false; try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; } @@ -2310,144 +2314,6 @@ export function EmailViewer({ }, [effectiveAttachments, attachmentPosition, imageThumbUrls]); // Generate email source for viewing - const generateEmailSource = (email: Email): string => { - let source = ''; - - // Headers - source += '=== EMAIL HEADERS ===\n\n'; - if (email.messageId) source += `Message-ID: ${email.messageId}\n`; - if (email.from) source += `From: ${email.from.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.to) source += `To: ${email.to.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.cc) source += `Cc: ${email.cc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.bcc) source += `Bcc: ${email.bcc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.replyTo) source += `Reply-To: ${email.replyTo.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; - if (email.subject) source += `Subject: ${email.subject}\n`; - if (email.sentAt) source += `Date: ${new Date(email.sentAt).toUTCString()}\n`; - if (email.receivedAt) source += `Received-At: ${new Date(email.receivedAt).toUTCString()}\n`; - if (email.inReplyTo) source += `In-Reply-To: ${email.inReplyTo.join(', ')}\n`; - if (email.references) source += `References: ${email.references.join(', ')}\n`; - - // Additional headers - if (email.headers) { - source += '\n--- Additional Headers ---\n'; - // Headers should now always be a Record after client processing - Object.entries(email.headers).forEach(([key, value]) => { - const val = Array.isArray(value) ? value.join('\n ') : String(value); - source += `${key}: ${val}\n`; - }); - } - - // Authentication results - if (email.authenticationResults) { - source += '\n--- Authentication Results ---\n'; - if (email.authenticationResults.spf) { - source += `SPF: ${email.authenticationResults.spf.result}`; - if (email.authenticationResults.spf.domain) source += ` (${email.authenticationResults.spf.domain})`; - source += '\n'; - } - if (email.authenticationResults.dkim) { - source += `DKIM: ${email.authenticationResults.dkim.result}`; - if (email.authenticationResults.dkim.domain) source += ` (${email.authenticationResults.dkim.domain})`; - source += '\n'; - } - if (email.authenticationResults.dmarc) { - source += `DMARC: ${email.authenticationResults.dmarc.result}`; - if (email.authenticationResults.dmarc.policy) source += ` policy=${email.authenticationResults.dmarc.policy}`; - source += '\n'; - } - } - - if (email.spamScore !== undefined) { - source += `Spam Score: ${email.spamScore}`; - if (email.spamStatus) source += ` (${email.spamStatus})`; - source += '\n'; - } - - // Metadata - source += '\n=== EMAIL METADATA ===\n\n'; - source += `Email ID: ${email.id}\n`; - source += `Thread ID: ${email.threadId}\n`; - source += `Size: ${formatFileSize(email.size)}\n`; - source += `Has Attachment: ${email.hasAttachment ? 'Yes' : 'No'}\n`; - if (email.keywords) { - const keywords = Object.entries(email.keywords) - .filter(([_, v]) => v) - .map(([k]) => k) - .join(', '); - if (keywords) source += `Keywords: ${keywords}\n`; - } - - // Attachments - if (email.attachments && email.attachments.length > 0) { - source += '\n=== ATTACHMENTS ===\n\n'; - email.attachments.forEach((att, i) => { - source += `[${i + 1}] ${att.name || 'Unnamed'}\n`; - source += ` Type: ${att.type}\n`; - source += ` Size: ${formatFileSize(att.size)}\n`; - source += ` Blob ID: ${att.blobId}\n`; - if (att.cid) source += ` Content-ID: ${att.cid}\n`; - source += '\n'; - }); - } - - // Body content - source += '\n=== EMAIL BODY ===\n\n'; - - let hasBodyContent = false; - - // Text version - if (email.textBody?.[0]?.partId && email.bodyValues?.[email.textBody[0].partId]) { - const textValue = email.bodyValues[email.textBody[0].partId].value; - if (textValue && textValue.trim()) { - source += '--- Plain Text Version ---\n\n'; - source += textValue; - source += '\n\n'; - hasBodyContent = true; - } - } - - // HTML version - if (email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]) { - const htmlValue = email.bodyValues[email.htmlBody[0].partId].value; - if (htmlValue && htmlValue.trim()) { - source += '--- HTML Version ---\n\n'; - source += htmlValue; - source += '\n\n'; - hasBodyContent = true; - } - } - - // All body values if we haven't found content yet - if (!hasBodyContent && email.bodyValues) { - const bodyKeys = Object.keys(email.bodyValues); - if (bodyKeys.length > 0) { - source += '--- Body Parts ---\n\n'; - bodyKeys.forEach((key, index) => { - const bodyValue = email.bodyValues![key].value; - if (bodyValue && bodyValue.trim()) { - source += `Part ${index + 1} (${key}):\n`; - source += bodyValue; - source += '\n\n'; - hasBodyContent = true; - } - }); - } - } - - // Preview if no body - if (!hasBodyContent && email.preview) { - source += '--- Preview Only ---\n\n'; - source += email.preview; - source += '\n'; - } - - if (!hasBodyContent && !email.preview) { - source += '(No body content available)\n'; - } - - return source; - }; - const copySourceToClipboard = async () => { if (!email) return; @@ -4861,6 +4727,22 @@ export function EmailViewer({ const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost); const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM); + // Projected, read-only view handed to plugins that render in the + // "more details" panel. Includes the parsed `headers` map and full + // `source` so plugins can inspect raw headers / message source. + // Built lazily here - only when the details panel is expanded. + const detailsView = emailToReadView(email); + // Lets a plugin add rows under an existing category. The plugin's + // own `shouldShow({ email, category })` decides which category it + // appears under (or `category === null` for the new bottom section). + const CategorySlot = ({ category }: { category: string }) => ( + + ); + return (
@@ -4918,6 +4800,7 @@ export function EmailViewer({ )} + {hasAuthSection && ( @@ -5003,6 +4886,7 @@ export function EmailViewer({
)} + )} @@ -5037,6 +4921,7 @@ export function EmailViewer({ {email.threadId} )} + )} @@ -5064,6 +4949,7 @@ export function EmailViewer({ {email.accountLabel} )} + {hasListInfo && ( @@ -5089,6 +4975,18 @@ export function EmailViewer({ {listHeaders.listPost} )} + + + )} + + {/* Plugin-supplied category. Plugins whose shouldShow accepts + `category === null` render their own titled section here. */} + {hasDetailsSlotOffers && ( +
+
)} diff --git a/lib/email-source.ts b/lib/email-source.ts new file mode 100644 index 00000000..7a80a2f1 --- /dev/null +++ b/lib/email-source.ts @@ -0,0 +1,145 @@ +// Shared "View source" renderer. Builds a human-readable dump of a message's +// headers, metadata and body from its JMAP Email object. Used both by the +// email viewer's source modal and by the plugin projection so plugins see the +// exact same text the UI shows. Pure: depends only on the passed `email`. + +import type { Email } from '@/lib/jmap/types'; +import { formatFileSize } from '@/lib/utils'; + +export function generateEmailSource(email: Email): string { + let source = ''; + + // Headers + source += '=== EMAIL HEADERS ===\n\n'; + if (email.messageId) source += `Message-ID: ${email.messageId}\n`; + if (email.from) source += `From: ${email.from.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.to) source += `To: ${email.to.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.cc) source += `Cc: ${email.cc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.bcc) source += `Bcc: ${email.bcc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.replyTo) source += `Reply-To: ${email.replyTo.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`; + if (email.subject) source += `Subject: ${email.subject}\n`; + if (email.sentAt) source += `Date: ${new Date(email.sentAt).toUTCString()}\n`; + if (email.receivedAt) source += `Received-At: ${new Date(email.receivedAt).toUTCString()}\n`; + if (email.inReplyTo) source += `In-Reply-To: ${email.inReplyTo.join(', ')}\n`; + if (email.references) source += `References: ${email.references.join(', ')}\n`; + + // Additional headers + if (email.headers) { + source += '\n--- Additional Headers ---\n'; + // Headers should now always be a Record after client processing + Object.entries(email.headers).forEach(([key, value]) => { + const val = Array.isArray(value) ? value.join('\n ') : String(value); + source += `${key}: ${val}\n`; + }); + } + + // Authentication results + if (email.authenticationResults) { + source += '\n--- Authentication Results ---\n'; + if (email.authenticationResults.spf) { + source += `SPF: ${email.authenticationResults.spf.result}`; + if (email.authenticationResults.spf.domain) source += ` (${email.authenticationResults.spf.domain})`; + source += '\n'; + } + if (email.authenticationResults.dkim) { + source += `DKIM: ${email.authenticationResults.dkim.result}`; + if (email.authenticationResults.dkim.domain) source += ` (${email.authenticationResults.dkim.domain})`; + source += '\n'; + } + if (email.authenticationResults.dmarc) { + source += `DMARC: ${email.authenticationResults.dmarc.result}`; + if (email.authenticationResults.dmarc.policy) source += ` policy=${email.authenticationResults.dmarc.policy}`; + source += '\n'; + } + } + + if (email.spamScore !== undefined) { + source += `Spam Score: ${email.spamScore}`; + if (email.spamStatus) source += ` (${email.spamStatus})`; + source += '\n'; + } + + // Metadata + source += '\n=== EMAIL METADATA ===\n\n'; + source += `Email ID: ${email.id}\n`; + source += `Thread ID: ${email.threadId}\n`; + source += `Size: ${formatFileSize(email.size)}\n`; + source += `Has Attachment: ${email.hasAttachment ? 'Yes' : 'No'}\n`; + if (email.keywords) { + const keywords = Object.entries(email.keywords) + .filter(([_, v]) => v) + .map(([k]) => k) + .join(', '); + if (keywords) source += `Keywords: ${keywords}\n`; + } + + // Attachments + if (email.attachments && email.attachments.length > 0) { + source += '\n=== ATTACHMENTS ===\n\n'; + email.attachments.forEach((att, i) => { + source += `[${i + 1}] ${att.name || 'Unnamed'}\n`; + source += ` Type: ${att.type}\n`; + source += ` Size: ${formatFileSize(att.size)}\n`; + source += ` Blob ID: ${att.blobId}\n`; + if (att.cid) source += ` Content-ID: ${att.cid}\n`; + source += '\n'; + }); + } + + // Body content + source += '\n=== EMAIL BODY ===\n\n'; + + let hasBodyContent = false; + + // Text version + if (email.textBody?.[0]?.partId && email.bodyValues?.[email.textBody[0].partId]) { + const textValue = email.bodyValues[email.textBody[0].partId].value; + if (textValue && textValue.trim()) { + source += '--- Plain Text Version ---\n\n'; + source += textValue; + source += '\n\n'; + hasBodyContent = true; + } + } + + // HTML version + if (email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]) { + const htmlValue = email.bodyValues[email.htmlBody[0].partId].value; + if (htmlValue && htmlValue.trim()) { + source += '--- HTML Version ---\n\n'; + source += htmlValue; + source += '\n\n'; + hasBodyContent = true; + } + } + + // All body values if we haven't found content yet + if (!hasBodyContent && email.bodyValues) { + const bodyKeys = Object.keys(email.bodyValues); + if (bodyKeys.length > 0) { + source += '--- Body Parts ---\n\n'; + bodyKeys.forEach((key, index) => { + const bodyValue = email.bodyValues![key].value; + if (bodyValue && bodyValue.trim()) { + source += `Part ${index + 1} (${key}):\n`; + source += bodyValue; + source += '\n\n'; + hasBodyContent = true; + } + }); + } + } + + // Preview if no body + if (!hasBodyContent && email.preview) { + source += '--- Preview Only ---\n\n'; + source += email.preview; + source += '\n'; + } + + if (!hasBodyContent && !email.preview) { + source += '(No body content available)\n'; + } + + return source; +} diff --git a/lib/plugin-projection.ts b/lib/plugin-projection.ts index 5199db8e..865df43e 100644 --- a/lib/plugin-projection.ts +++ b/lib/plugin-projection.ts @@ -4,6 +4,7 @@ import type { Email } from '@/lib/jmap/types'; import type { EmailReadView } from '@/lib/plugin-types'; +import { generateEmailSource } from '@/lib/email-source'; // Resolve a message's plain-text body from its JMAP body parts. Plugins that // translate or scan content need the real body, not just the short `preview` @@ -54,6 +55,8 @@ export function emailToReadView(email: Email): EmailReadView { hasAttachment: email.hasAttachment, preview: email.preview || '', text: plainTextFromEmail(email), + headers: email.headers, + source: generateEmailSource(email), keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]), auth: email.authenticationResults, }; diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index ca4ed70e..a7728746 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -260,6 +260,7 @@ export type SlotName = | 'composer-sidebar-right' | 'sidebar-widget' | 'email-detail-sidebar' + | 'email-details-section' | 'settings-section' | 'context-menu-email' | 'navigation-rail-bottom' @@ -378,6 +379,19 @@ export interface EmailReadView { * text). Empty string when the host hasn't loaded the body. Same * `email:read` sensitivity as the rest of this view. */ text: string; + /** + * Raw parsed header map (header name → value, or values when a header + * appears more than once), exactly as JMAP returned it. Absent until the + * host has loaded the message's headers. Same `email:read` sensitivity as + * the rest of this view. + */ + headers?: Record; + /** + * Full, human-readable message source — headers, metadata and body — the + * same text the "View source" dialog shows. Empty string when the body + * hasn't been fetched. Gated by `email:read` like the rest of this view. + */ + source: string; /** * Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS). * Absent on stores that didn't parse the header (e.g. bodies not yet @@ -852,6 +866,7 @@ export const ALL_PERMISSIONS = [ 'auth:observe', 'http:post', 'http:fetch', 'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer', + 'ui:email-details', 'ui:composer-toolbar', 'ui:composer-sidebar', 'ui:sidebar-widget', 'ui:settings-section', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',