Files
webmail/lib/email-source.ts
Linus Rath d7004b3851 fix: report refused tags and keep Stalwart 1.0's attachment keywords intact
Stalwart 1.0 puts `$hasattachment` or `$hasnoattachment` on every
message and derives `hasAttachment` from them alone. It also refuses
keywords over 128 bytes (0.16 cuts them to 128 characters) and more than
100 keywords on one message. The keyword writers ignored Email/set
`notUpdated`, so a refused tag showed until the next reload and then
vanished.

- `markAsRead`, `batchMarkAsRead`, `toggleStar`, `updateEmailKeywords`,
  `setKeyword`, `removeKeyword` and `batchUpdateKeywords` check the
  Email/set outcome, and the errors carry the JMAP type. Tagging from
  the list or the viewer shows "Tagging failed"; a refused category-tab
  move rejects the plugin's promise and skips the categorize hooks.
- Tag ids are capped at 121 characters (128 bytes with `$label:`).
- The attachment keywords are hidden in view source, the plugin
  projection and the `getKeywords` fallback labels, reserved for
  category tabs, and refused by the plugin keyword API. Email/copy and
  the cross-server import still carry them over.
- A tag comes off by leaving its key out of the full keyword map instead
  of being sent as false, which RFC 8621 does not allow;
  `updateEmailKeywords` also drops the false entries local updates leave
  behind (`$seen: false` after marking unread).
- The automatic mark-as-read no longer leaves an unhandled rejection
  when the server refuses it.
2026-09-25 19:24:30 +02:00

148 lines
5.6 KiB
TypeScript

// 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 { isServerSetKeyword } from '@/lib/jmap/server-keywords';
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) {
// Stalwart 1.0's attachment keywords only restate the line above.
const keywords = Object.entries(email.keywords)
.filter(([k, v]) => v && !isServerSetKeyword(k))
.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;
}