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:
@@ -423,6 +423,7 @@ export default function Home() {
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
htmlBody?: string;
|
||||
draftId?: string;
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
@@ -431,7 +432,7 @@ export default function Home() {
|
||||
if (!client) return;
|
||||
|
||||
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);
|
||||
|
||||
// Refresh the current mailbox to update the UI
|
||||
@@ -1349,6 +1350,7 @@ export default function Home() {
|
||||
cc: selectedEmail.cc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
receivedAt: selectedEmail.receivedAt
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
|
||||
@@ -242,6 +242,26 @@ body {
|
||||
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 */
|
||||
.email-content table.data-table,
|
||||
.email-content table[border="1"] {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
@@ -42,6 +43,7 @@ interface EmailComposerProps {
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
htmlBody?: string;
|
||||
draftId?: string;
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
@@ -60,6 +62,7 @@ interface EmailComposerProps {
|
||||
cc?: { email?: string; name?: string }[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
htmlBody?: string;
|
||||
receivedAt?: string;
|
||||
};
|
||||
}
|
||||
@@ -113,16 +116,22 @@ export function EmailComposer({
|
||||
|
||||
const getInitialBody = () => {
|
||||
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 from = replyTo.from?.[0];
|
||||
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') {
|
||||
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
|
||||
} 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;
|
||||
};
|
||||
@@ -138,6 +147,18 @@ export function EmailComposer({
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||
@@ -147,6 +168,7 @@ export function EmailComposer({
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||
|
||||
const saveTemplateModalRef = useFocusTrap({
|
||||
isActive: showSaveAsTemplate,
|
||||
@@ -333,13 +355,9 @@ export function EmailComposer({
|
||||
return () => window.removeEventListener('keydown', handleTemplateKey);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!client || !event.target.files) return;
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
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 controller = new AbortController();
|
||||
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) {
|
||||
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 att = attachments[index];
|
||||
att?.abortController?.abort();
|
||||
@@ -558,6 +624,23 @@ export function EmailComposer({
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
const signatureHtml = currentIdentity?.textSignature
|
||||
? `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').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 {
|
||||
await onSend?.({
|
||||
to: toAddresses,
|
||||
@@ -565,6 +648,7 @@ export function EmailComposer({
|
||||
bcc: bccAddresses,
|
||||
subject,
|
||||
body: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
draftId: finalDraftId || undefined,
|
||||
fromEmail,
|
||||
fromName: currentIdentity?.name || undefined,
|
||||
@@ -626,7 +710,22 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -668,7 +767,7 @@ export function EmailComposer({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{/* Fields section */}
|
||||
<div className="space-y-0 border-b">
|
||||
{/* From field */}
|
||||
@@ -834,10 +933,11 @@ export function EmailComposer({
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 px-4 py-3 min-h-0">
|
||||
<div className="px-4 py-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
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"
|
||||
)}
|
||||
placeholder={t('body_placeholder')}
|
||||
@@ -850,11 +950,29 @@ export function EmailComposer({
|
||||
/>
|
||||
</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.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">
|
||||
{attachments.map((att, index) => (
|
||||
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
@@ -890,12 +1008,20 @@ export function EmailComposer({
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
@@ -955,7 +1081,6 @@ export function EmailComposer({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTemplatePicker && (
|
||||
<TemplatePicker
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
FolderInput,
|
||||
Inbox,
|
||||
Folder,
|
||||
Sun,
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
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 { useAuthStore } from "@/stores/auth-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
@@ -613,6 +614,7 @@ export function EmailViewer({
|
||||
setQuickReplyText("");
|
||||
setIsQuickReplyFocused(false);
|
||||
setShowSourceModal(false);
|
||||
setEmailViewDarkOverride(null);
|
||||
}, [email?.id, externalContentPolicy]);
|
||||
|
||||
// Fetch inline CID images with authentication to prevent browser auth dialogs
|
||||
@@ -894,25 +896,7 @@ export function EmailViewer({
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
|
||||
if (resolvedTheme === 'dark') {
|
||||
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));
|
||||
}
|
||||
}
|
||||
// No dark mode color transforms - emails render true-to-life in iframe
|
||||
});
|
||||
|
||||
// Sanitize HTML to prevent XSS
|
||||
@@ -979,7 +963,75 @@ export function EmailViewer({
|
||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||
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
|
||||
const handlePrint = () => {
|
||||
@@ -2132,6 +2184,15 @@ export function EmailViewer({
|
||||
{formatFileSize(email.size)}
|
||||
</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>
|
||||
|
||||
@@ -2717,14 +2778,14 @@ export function EmailViewer({
|
||||
{/* Email Body */}
|
||||
<div className="email-content-wrapper overflow-x-auto">
|
||||
{emailContent.isHtml ? (
|
||||
<div
|
||||
className="email-content prose dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
style={{
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={emailIframeSrcDoc}
|
||||
sandbox="allow-same-origin allow-popups"
|
||||
title="Email content"
|
||||
className="w-full border-0 rounded"
|
||||
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
+27
-14
@@ -1379,7 +1379,8 @@ export class JMAPClient {
|
||||
identityId?: string,
|
||||
fromEmail?: string,
|
||||
draftId?: string,
|
||||
fromName?: string
|
||||
fromName?: string,
|
||||
htmlBody?: string
|
||||
): Promise<void> {
|
||||
const emailId = draftId || `draft-${Date.now()}`;
|
||||
const mailboxes = await this.getMailboxes();
|
||||
@@ -1422,21 +1423,33 @@ export class JMAPClient {
|
||||
create: { "1": { emailId: draftId, identityId: finalIdentityId } },
|
||||
}, "1"]);
|
||||
} 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", {
|
||||
accountId: this.accountId,
|
||||
create: {
|
||||
[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" }],
|
||||
},
|
||||
},
|
||||
create: { [emailId]: emailCreate },
|
||||
}, "0"]);
|
||||
methodCalls.push(["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
|
||||
@@ -362,6 +362,8 @@
|
||||
"upload_progress": "Uploading {uploaded} / {total}",
|
||||
"upload_cancel": "Cancel upload",
|
||||
"upload_failed": "Failed to upload {filename}",
|
||||
"drop_files": "Drop files to attach",
|
||||
"show_less": "Show less",
|
||||
"send_failed": "Failed to send email",
|
||||
"continue_draft": "Continue draft",
|
||||
"close_draft_title": "Save or discard draft?",
|
||||
|
||||
@@ -61,7 +61,7 @@ interface EmailStore {
|
||||
loadMoreEmails: (client: JMAPClient) => Promise<void>;
|
||||
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
|
||||
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>;
|
||||
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => 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 });
|
||||
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
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user