feat: add plugin hooks for email details, headers, and source

This commit is contained in:
Linus Rath
2026-06-25 01:04:26 +02:00
parent de56229ef2
commit 155d99a069
4 changed files with 199 additions and 138 deletions
+36 -138
View File
@@ -13,6 +13,7 @@ import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection"; import { emailToReadView } from "@/lib/plugin-projection";
import { generateEmailSource } from "@/lib/email-source";
import { import {
Reply, Reply,
ReplyAll, ReplyAll,
@@ -1053,6 +1054,9 @@ export function EmailViewer({
// sessions so the panel reopens the way the user last left it. // sessions so the panel reopens the way the user last left it.
const detailSlots = usePluginSlotOffers('email-detail-sidebar'); const detailSlots = usePluginSlotOffers('email-detail-sidebar');
const hasDetailSidebar = detailSlots.length > 0; 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<boolean>(() => { const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; } try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
@@ -2310,144 +2314,6 @@ export function EmailViewer({
}, [effectiveAttachments, attachmentPosition, imageThumbUrls]); }, [effectiveAttachments, attachmentPosition, imageThumbUrls]);
// Generate email source for viewing // 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 () => { const copySourceToClipboard = async () => {
if (!email) return; if (!email) return;
@@ -4861,6 +4727,22 @@ export function EmailViewer({
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost); 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); 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 }) => (
<PluginSlot
name="email-details-section"
className="mt-2 empty:mt-0"
extraProps={{ email: detailsView, category }}
/>
);
return ( return (
<div className="bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}> <div className="bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
@@ -4918,6 +4800,7 @@ export function EmailViewer({
)} )}
</Row> </Row>
</dl> </dl>
<CategorySlot category="recipients_routing" />
</section> </section>
{hasAuthSection && ( {hasAuthSection && (
@@ -5003,6 +4886,7 @@ export function EmailViewer({
</div> </div>
</div> </div>
)} )}
<CategorySlot category="authentication_security" />
</section> </section>
)} )}
@@ -5037,6 +4921,7 @@ export function EmailViewer({
<Row label={t('details.thread_id')} mono>{email.threadId}</Row> <Row label={t('details.thread_id')} mono>{email.threadId}</Row>
)} )}
</dl> </dl>
<CategorySlot category="identifiers_threading" />
</section> </section>
)} )}
@@ -5064,6 +4949,7 @@ export function EmailViewer({
<Row label={t('details.account')}>{email.accountLabel}</Row> <Row label={t('details.account')}>{email.accountLabel}</Row>
)} )}
</dl> </dl>
<CategorySlot category="message_properties" />
</section> </section>
{hasListInfo && ( {hasListInfo && (
@@ -5089,6 +4975,18 @@ export function EmailViewer({
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row> <Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
)} )}
</dl> </dl>
<CategorySlot category="mailing_list" />
</section>
)}
{/* Plugin-supplied category. Plugins whose shouldShow accepts
`category === null` render their own titled section here. */}
{hasDetailsSlotOffers && (
<section className="lg:col-span-2 min-w-0">
<PluginSlot
name="email-details-section"
extraProps={{ email: detailsView, category: null }}
/>
</section> </section>
)} )}
</div> </div>
+145
View File
@@ -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;
}
+3
View File
@@ -4,6 +4,7 @@
import type { Email } from '@/lib/jmap/types'; import type { Email } from '@/lib/jmap/types';
import type { EmailReadView } from '@/lib/plugin-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 // 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` // 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, hasAttachment: email.hasAttachment,
preview: email.preview || '', preview: email.preview || '',
text: plainTextFromEmail(email), text: plainTextFromEmail(email),
headers: email.headers,
source: generateEmailSource(email),
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]), keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
auth: email.authenticationResults, auth: email.authenticationResults,
}; };
+15
View File
@@ -260,6 +260,7 @@ export type SlotName =
| 'composer-sidebar-right' | 'composer-sidebar-right'
| 'sidebar-widget' | 'sidebar-widget'
| 'email-detail-sidebar' | 'email-detail-sidebar'
| 'email-details-section'
| 'settings-section' | 'settings-section'
| 'context-menu-email' | 'context-menu-email'
| 'navigation-rail-bottom' | 'navigation-rail-bottom'
@@ -378,6 +379,19 @@ export interface EmailReadView {
* text). Empty string when the host hasn't loaded the body. Same * text). Empty string when the host hasn't loaded the body. Same
* `email:read` sensitivity as the rest of this view. */ * `email:read` sensitivity as the rest of this view. */
text: string; 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<string, string | string[]>;
/**
* 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). * Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS).
* Absent on stores that didn't parse the header (e.g. bodies not yet * Absent on stores that didn't parse the header (e.g. bodies not yet
@@ -852,6 +866,7 @@ export const ALL_PERMISSIONS = [
'auth:observe', 'auth:observe',
'http:post', 'http:fetch', 'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer', 'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
'ui:email-details',
'ui:composer-toolbar', 'ui:composer-sidebar', 'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section', 'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',