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
+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 { 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,
};
+15
View File
@@ -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<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).
* 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',