feat: iframe-based email rendering with smart dark mode support

- Render HTML emails in sandboxed iframe (srcdoc) for true-to-life
  display with complete CSS isolation from app styles
- Detect emails with native dark mode (prefers-color-scheme) and
  let them handle their own theming
- Apply CSS filter inversion for dark mode on emails without native
  support, with re-inversion for images/media to preserve appearance
- Add per-email light/dark toggle button (Sun/Moon icon) next to
  email size, resets on email change (not persisted)
- Fix HTML reply/forward to include original email HTML content
- Send replies as multipart/alternative (text + HTML)
- Add drag-and-drop file attachments with overlay indicator
- Auto-resize composer textarea to avoid double scrolling
- Pin attachments section and bottom toolbar outside scroll area
- Collapsible attachment list (show 3, toggle for more)
This commit is contained in:
Linus Rath
2026-03-15 17:42:41 +01:00
parent 0965fbc7c1
commit 9bffb72338
7 changed files with 287 additions and 64 deletions
+3 -1
View File
@@ -423,6 +423,7 @@ export default function Home() {
bcc: string[]; bcc: string[];
subject: string; subject: string;
body: string; body: string;
htmlBody?: string;
draftId?: string; draftId?: string;
fromEmail?: string; fromEmail?: string;
fromName?: string; fromName?: string;
@@ -431,7 +432,7 @@ export default function Home() {
if (!client) return; if (!client) return;
try { try {
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName); await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody);
setShowComposer(false); setShowComposer(false);
// Refresh the current mailbox to update the UI // Refresh the current mailbox to update the UI
@@ -1349,6 +1350,7 @@ export default function Home() {
cc: selectedEmail.cc, cc: selectedEmail.cc,
subject: selectedEmail.subject, subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt receivedAt: selectedEmail.receivedAt
} : undefined)} } : undefined)}
initialDraftText={composerDraftText} initialDraftText={composerDraftText}
+20
View File
@@ -242,6 +242,26 @@ body {
list-style-type: decimal; list-style-type: decimal;
} }
/* Reply quoted HTML - preserves original inline styles/colors */
.email-reply-quote {
overflow-wrap: break-word;
word-wrap: break-word;
max-width: none;
}
.email-reply-quote p {
margin: 0.5rem 0;
}
.email-reply-quote img {
max-width: 100%;
height: auto;
}
.email-reply-quote a {
text-decoration: underline;
}
/* Only style tables that are actual data tables, not layout tables */ /* Only style tables that are actual data tables, not layout tables */
.email-content table.data-table, .email-content table.data-table,
.email-content table[border="1"] { .email-content table[border="1"] {
+141 -16
View File
@@ -9,6 +9,7 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
@@ -42,6 +43,7 @@ interface EmailComposerProps {
bcc: string[]; bcc: string[];
subject: string; subject: string;
body: string; body: string;
htmlBody?: string;
draftId?: string; draftId?: string;
fromEmail?: string; fromEmail?: string;
fromName?: string; fromName?: string;
@@ -60,6 +62,7 @@ interface EmailComposerProps {
cc?: { email?: string; name?: string }[]; cc?: { email?: string; name?: string }[];
subject?: string; subject?: string;
body?: string; body?: string;
htmlBody?: string;
receivedAt?: string; receivedAt?: string;
}; };
} }
@@ -113,16 +116,22 @@ export function EmailComposer({
const getInitialBody = () => { const getInitialBody = () => {
const prefix = initialDraftText || ""; const prefix = initialDraftText || "";
if (!replyTo?.body) return prefix; if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ""; const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
// When HTML body is available, don't include quoted text in the textarea
// The HTML original will be shown separately below the textarea
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
return prefix;
}
if (mode === 'forward') { if (mode === 'forward') {
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${replyTo.body.split('\n').join('\n> ')}`; return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${(replyTo.body || '').split('\n').join('\n> ')}`;
} }
return prefix; return prefix;
}; };
@@ -138,6 +147,18 @@ export function EmailComposer({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>(""); const lastSavedDataRef = useRef<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResizeTextarea = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}, []);
useEffect(() => {
autoResizeTextarea();
}, [body, autoResizeTextarea]);
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]); const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
@@ -147,6 +168,7 @@ export function EmailComposer({
const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false);
const [showAllAttachments, setShowAllAttachments] = useState(false);
const saveTemplateModalRef = useFocusTrap({ const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate, isActive: showSaveAsTemplate,
@@ -333,13 +355,9 @@ export function EmailComposer({
return () => window.removeEventListener('keydown', handleTemplateKey); return () => window.removeEventListener('keydown', handleTemplateKey);
}, []); }, []);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => { const addFiles = useCallback(async (files: File[]) => {
if (!client || !event.target.files) return; if (!client || files.length === 0) return;
const files = Array.from(event.target.files);
// AbortController tracks cancellation state but uploadBlob doesn't accept a signal,
// so abort only prevents post-upload state updates (cosmetic cancellation)
const newAttachments = files.map(file => { const newAttachments = files.map(file => {
const controller = new AbortController(); const controller = new AbortController();
return { file, uploading: true, abortController: controller }; return { file, uploading: true, abortController: controller };
@@ -375,12 +393,60 @@ export function EmailComposer({
); );
} }
} }
}, [client, t]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;
await addFiles(Array.from(event.target.files));
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}; };
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const clearDragState = useCallback(() => {
if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current);
dragTimeoutRef.current = null;
setIsDraggingOver(false);
}, []);
const resetDragTimeout = useCallback(() => {
if (dragTimeoutRef.current) clearTimeout(dragTimeoutRef.current);
dragTimeoutRef.current = setTimeout(clearDragState, 150);
}, [clearDragState]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files')) {
setIsDraggingOver(true);
resetDragTimeout();
}
}, [resetDragTimeout]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
resetDragTimeout();
}, [resetDragTimeout]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
resetDragTimeout();
}, [resetDragTimeout]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
clearDragState();
if (e.dataTransfer.files?.length) {
addFiles(Array.from(e.dataTransfer.files));
}
}, [addFiles, clearDragState]);
const removeAttachment = (index: number) => { const removeAttachment = (index: number) => {
const att = attachments[index]; const att = attachments[index];
att?.abortController?.abort(); att?.abortController?.abort();
@@ -558,6 +624,23 @@ export function EmailComposer({
finalBody = body + '\n\n-- \n' + currentIdentity.textSignature; finalBody = body + '\n\n-- \n' + currentIdentity.textSignature;
} }
// Build HTML body when replying/forwarding with original HTML content
let finalHtmlBody: string | undefined;
if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const escapedBody = body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
const signatureHtml = currentIdentity?.textSignature
? `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`
: '';
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : '';
const fromAddr = replyTo.from?.[0];
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
const quoteHeader = mode === 'forward'
? `---------- ${t('prefix.forward')} ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`;
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}<br><div><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote></div>`;
}
try { try {
await onSend?.({ await onSend?.({
to: toAddresses, to: toAddresses,
@@ -565,6 +648,7 @@ export function EmailComposer({
bcc: bccAddresses, bcc: bccAddresses,
subject, subject,
body: finalBody, body: finalBody,
htmlBody: finalHtmlBody,
draftId: finalDraftId || undefined, draftId: finalDraftId || undefined,
fromEmail, fromEmail,
fromName: currentIdentity?.name || undefined, fromName: currentIdentity?.name || undefined,
@@ -626,7 +710,22 @@ export function EmailComposer({
}; };
return ( return (
<div className={cn("flex flex-col h-full bg-background", className)}> <div
className={cn("flex flex-col h-full bg-background relative", className)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDraggingOver && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 border-2 border-dashed border-primary rounded-lg pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<Paperclip className="w-8 h-8" />
<span className="text-sm font-medium">{t('drop_files')}</span>
</div>
</div>
)}
{/* Header - mobile: clean bar with close/send, desktop: title bar */} {/* Header - mobile: clean bar with close/send, desktop: title bar */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-background"> <div className="flex items-center justify-between px-4 py-3 border-b bg-background">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -668,7 +767,7 @@ export function EmailComposer({
</Button> </Button>
</div> </div>
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 min-h-0 overflow-auto">
{/* Fields section */} {/* Fields section */}
<div className="space-y-0 border-b"> <div className="space-y-0 border-b">
{/* From field */} {/* From field */}
@@ -834,10 +933,11 @@ export function EmailComposer({
</div> </div>
{/* Body */} {/* Body */}
<div className="flex-1 px-4 py-3 min-h-0"> <div className="px-4 py-3">
<textarea <textarea
ref={textareaRef}
className={cn( className={cn(
"w-full h-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded", "w-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded min-h-[100px] overflow-hidden",
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400" validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400"
)} )}
placeholder={t('body_placeholder')} placeholder={t('body_placeholder')}
@@ -850,11 +950,29 @@ export function EmailComposer({
/> />
</div> </div>
{/* Quoted original HTML */}
{replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
<div className="border-t border-border">
<div className="px-4 py-2 text-xs text-muted-foreground">
{mode === 'forward'
? `---------- ${t('prefix.forward')} ----------`
: `${replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ''}, ${replyTo.from?.[0]?.name || replyTo.from?.[0]?.email || tCommon('unknown')}:`
}
</div>
<div
className="email-reply-quote px-4 pb-3 border-l-2 border-muted-foreground/30 ml-4 max-w-none rounded"
style={{ backgroundColor: '#ffffff', color: '#1a1a1a', fontSize: '14px' }}
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(replyTo.htmlBody) }}
/>
</div>
)}
</div>
{/* Attachments */} {/* Attachments */}
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="px-4 py-2 border-t"> <div className="px-4 py-2 border-t shrink-0">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{attachments.map((att, index) => ( {(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => (
<div <div
key={index} key={index}
className={cn( className={cn(
@@ -890,12 +1008,20 @@ export function EmailComposer({
</div> </div>
</div> </div>
))} ))}
{attachments.length > 3 && (
<button
onClick={() => setShowAllAttachments(prev => !prev)}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
{showAllAttachments ? t('show_less') : `+${attachments.length - 3}`}
</button>
)}
</div> </div>
</div> </div>
)} )}
{/* Bottom toolbar */} {/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background"> <div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0">
{/* Left side actions */} {/* Left side actions */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <input
@@ -955,7 +1081,6 @@ export function EmailComposer({
</Button> </Button>
</div> </div>
</div> </div>
</div>
{showTemplatePicker && ( {showTemplatePicker && (
<TemplatePicker <TemplatePicker
+91 -30
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
@@ -57,6 +57,8 @@ import {
FolderInput, FolderInput,
Inbox, Inbox,
Folder, Folder,
Sun,
Moon,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
@@ -66,7 +68,6 @@ import { toast } from "@/stores/toast-store";
import { useDeviceDetection } from "@/hooks/use-media-query"; import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner"; import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner";
@@ -613,6 +614,7 @@ export function EmailViewer({
setQuickReplyText(""); setQuickReplyText("");
setIsQuickReplyFocused(false); setIsQuickReplyFocused(false);
setShowSourceModal(false); setShowSourceModal(false);
setEmailViewDarkOverride(null);
}, [email?.id, externalContentPolicy]); }, [email?.id, externalContentPolicy]);
// Fetch inline CID images with authentication to prevent browser auth dialogs // Fetch inline CID images with authentication to prevent browser auth dialogs
@@ -894,25 +896,7 @@ export function EmailViewer({
node.setAttribute('rel', 'noopener noreferrer'); node.setAttribute('rel', 'noopener noreferrer');
} }
if (resolvedTheme === 'dark') { // No dark mode color transforms - emails render true-to-life in iframe
if (htmlNode.style) {
const originalStyles = htmlNode.style.cssText;
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
if (transformedStyles !== originalStyles) {
htmlNode.style.cssText = transformedStyles;
}
}
const colorAttr = node.getAttribute('color');
if (colorAttr) {
node.setAttribute('color', transformColorForDarkMode(colorAttr));
}
const bgcolorAttr = node.getAttribute('bgcolor');
if (bgcolorAttr) {
node.setAttribute('bgcolor', transformBgColorForDarkMode(bgcolorAttr));
}
}
}); });
// Sanitize HTML to prevent XSS // Sanitize HTML to prevent XSS
@@ -979,7 +963,75 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>', html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false isHtml: false
}; };
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme, cidBlobUrls]); }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]);
// Iframe for rendering HTML emails true-to-life
const iframeRef = useRef<HTMLIFrameElement>(null);
// Detect if the email HTML has native dark mode support
const emailHasNativeDarkMode = useMemo(() => {
if (!emailContent.isHtml) return false;
return /prefers-color-scheme\s*:\s*dark/i.test(emailContent.html);
}, [emailContent.html, emailContent.isHtml]);
const [emailViewDarkOverride, setEmailViewDarkOverride] = useState<boolean | null>(null);
const isDark = emailViewDarkOverride !== null ? emailViewDarkOverride : resolvedTheme === 'dark';
const emailIframeSrcDoc = useMemo(() => {
if (!emailContent.isHtml) return '';
// If email has native dark mode, let it handle its own theming
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
html { background: #1a1a1a; }
body { filter: invert(1) hue-rotate(180deg); }
img, video, picture, svg, canvas, object, embed,
[style*="background-image"], [style*="background:"],
[background], [bgcolor],
td[background], table[background],
img[src], input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
` : '';
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { margin: 0; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100%; height: auto; }
a { color: #1a73e8; }
table { max-width: 100%; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${darkModeCSS}
</style></head><body>${emailContent.html}</body></html>`;
}, [emailContent.html, emailContent.isHtml, isDark, emailHasNativeDarkMode]);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const doc = iframe.contentDocument;
if (doc?.body) {
// Auto-resize iframe to fit content
const resizeObserver = new ResizeObserver(() => {
const height = doc.documentElement.scrollHeight;
iframe.style.height = height + 'px';
});
resizeObserver.observe(doc.body);
iframe.style.height = doc.documentElement.scrollHeight + 'px';
// Make links open in new tab
doc.querySelectorAll('a').forEach(a => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
}
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, []);
// Print only the email content in a new window // Print only the email content in a new window
const handlePrint = () => { const handlePrint = () => {
@@ -2132,6 +2184,15 @@ export function EmailViewer({
{formatFileSize(email.size)} {formatFileSize(email.size)}
</div> </div>
)} )}
{emailContent.isHtml && (
<button
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
className="inline-flex items-center rounded-full p-1 mt-1 text-muted-foreground/70 hover:text-foreground transition-colors hover:bg-muted"
title={isDark ? 'View in light mode' : 'View in dark mode'}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
)}
</div> </div>
</div> </div>
@@ -2717,14 +2778,14 @@ export function EmailViewer({
{/* Email Body */} {/* Email Body */}
<div className="email-content-wrapper overflow-x-auto"> <div className="email-content-wrapper overflow-x-auto">
{emailContent.isHtml ? ( {emailContent.isHtml ? (
<div <iframe
className="email-content prose dark:prose-invert max-w-none" ref={iframeRef}
dangerouslySetInnerHTML={{ __html: emailContent.html }} srcDoc={emailIframeSrcDoc}
style={{ sandbox="allow-same-origin allow-popups"
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', title="Email content"
fontSize: '14px', className="w-full border-0 rounded"
lineHeight: '1.6', style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
}} onLoad={handleIframeLoad}
/> />
) : ( ) : (
<div <div
+27 -14
View File
@@ -1379,7 +1379,8 @@ export class JMAPClient {
identityId?: string, identityId?: string,
fromEmail?: string, fromEmail?: string,
draftId?: string, draftId?: string,
fromName?: string fromName?: string,
htmlBody?: string
): Promise<void> { ): Promise<void> {
const emailId = draftId || `draft-${Date.now()}`; const emailId = draftId || `draft-${Date.now()}`;
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
@@ -1422,21 +1423,33 @@ export class JMAPClient {
create: { "1": { emailId: draftId, identityId: finalIdentityId } }, create: { "1": { emailId: draftId, identityId: finalIdentityId } },
}, "1"]); }, "1"]);
} else { } else {
// Build email body parts - include HTML if available
const emailCreate: Record<string, unknown> = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
};
if (htmlBody) {
// Send as multipart/alternative with both text and HTML
emailCreate.bodyValues = {
"text": { value: body },
"html": { value: htmlBody },
};
emailCreate.textBody = [{ partId: "text" }];
emailCreate.htmlBody = [{ partId: "html" }];
} else {
emailCreate.bodyValues = { "1": { value: body } };
emailCreate.textBody = [{ partId: "1" }];
}
methodCalls.push(["Email/set", { methodCalls.push(["Email/set", {
accountId: this.accountId, accountId: this.accountId,
create: { create: { [emailId]: emailCreate },
[emailId]: {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
bodyValues: { "1": { value: body } },
textBody: [{ partId: "1" }],
},
},
}, "0"]); }, "0"]);
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
+2
View File
@@ -362,6 +362,8 @@
"upload_progress": "Uploading {uploaded} / {total}", "upload_progress": "Uploading {uploaded} / {total}",
"upload_cancel": "Cancel upload", "upload_cancel": "Cancel upload",
"upload_failed": "Failed to upload {filename}", "upload_failed": "Failed to upload {filename}",
"drop_files": "Drop files to attach",
"show_less": "Show less",
"send_failed": "Failed to send email", "send_failed": "Failed to send email",
"continue_draft": "Continue draft", "continue_draft": "Continue draft",
"close_draft_title": "Save or discard draft?", "close_draft_title": "Save or discard draft?",
+3 -3
View File
@@ -61,7 +61,7 @@ interface EmailStore {
loadMoreEmails: (client: JMAPClient) => Promise<void>; loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>; fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>; fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string) => Promise<void>; sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>; deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>; markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>; moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -387,10 +387,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} }
}, },
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName) => { sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName); await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody);
// Refresh handled by UI layer for immediate feedback // Refresh handled by UI layer for immediate feedback
set({ isLoading: false }); set({ isLoading: false });
} catch (error) { } catch (error) {