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[];
|
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}
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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, '&').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 {
|
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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user