From ede603563edf1dfead59ede559cb52b03c53ee80 Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Mon, 16 Mar 2026 16:29:47 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v1.2.0=20=E2=80=94=20sandboxed=20email?= =?UTF-8?q?=20rendering,=20mobile=20UX,=20sidebar=20polish,=20API=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New features: - Sandboxed iframe rendering for rich HTML emails (CSS isolation) - Mobile bottom action bar (Reply, Archive, Delete, More) - Long-press context menu with haptic feedback on touch devices - Tag counts sidebar section with batch JMAP queries - Empty folder for Junk/Trash with batch delete progress - Extra-compact density option (28px desktop, 44px touch) - SPF/DKIM/DMARC security tooltips with plain-language explanations - Resizable sidebars with drag, touch, and keyboard support - Expandable sender info panel in email viewer - API retry with exponential backoff for transient JMAP failures - OAuth-only mode (OAUTH_ONLY env var) Improvements: - CSS-first responsive layout (no blink on orientation change) - Touch-friendly context menu submenus (tap-to-expand) - Click-to-toggle more-actions dropdown (was hover-only) - Wide HTML emails horizontally scrollable in iframe - All 8 locales updated with new translation keys --- CHANGELOG.md | 17 ++ README.md | 15 +- ROADMAP.md | 23 ++ app/[locale]/page.tsx | 85 ++++-- app/globals.css | 7 + components/email/email-list-item.tsx | 42 ++- components/email/email-list.tsx | 4 +- components/email/email-viewer.tsx | 279 ++++++++++++------ components/email/mobile-action-bar.tsx | 93 ++++++ components/email/sandboxed-email-frame.tsx | 80 +++++ components/email/sender-info-panel.tsx | 99 +++++++ components/email/thread-conversation-view.tsx | 29 +- components/layout/sidebar.tsx | 228 +++++++++++++- components/settings/appearance-settings.tsx | 3 +- components/ui/context-menu.tsx | 14 + hooks/use-long-press.ts | 67 +++++ hooks/use-resize-handle.ts | 108 +++++++ lib/__tests__/email-sanitization.test.ts | 51 ++++ lib/__tests__/jmap-retry.test.ts | 109 +++++++ lib/color-transform.ts | 37 +++ lib/email-sanitization.ts | 21 ++ lib/jmap/client.ts | 71 ++++- lib/jmap/retry.ts | 50 ++++ locales/de/common.json | 54 +++- locales/en/common.json | 52 +++- locales/es/common.json | 54 +++- locales/fr/common.json | 54 +++- locales/it/common.json | 54 +++- locales/ja/common.json | 54 +++- locales/nl/common.json | 54 +++- locales/pt/common.json | 54 +++- stores/email-store.ts | 58 ++++ stores/settings-store.ts | 27 +- 33 files changed, 1869 insertions(+), 178 deletions(-) create mode 100644 components/email/mobile-action-bar.tsx create mode 100644 components/email/sandboxed-email-frame.tsx create mode 100644 components/email/sender-info-panel.tsx create mode 100644 hooks/use-long-press.ts create mode 100644 hooks/use-resize-handle.ts create mode 100644 lib/__tests__/jmap-retry.test.ts create mode 100644 lib/jmap/retry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f99fc3..1a402a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,26 @@ ### Features +- **Sandboxed email rendering**: Rich HTML emails (newsletters, tables) now render in a sandboxed iframe for CSS isolation — prevents email styles from bleeding into the app UI +- **API retry with backoff**: JMAP requests now automatically retry on transient failures (503, 429, network errors) with exponential backoff +- **Mobile action bar**: Bottom toolbar with Reply, Reply All, Archive, Delete, and More actions when viewing emails on mobile +- **Long-press context menu**: Long-press on email list items triggers the context menu on touch devices, with haptic feedback +- **Tag counts in sidebar**: Collapsible Tags section shows color-coded tags with email counts +- **Empty folder**: One-click empty for Junk and Trash folders with confirmation and batch deletion progress +- **Extra-compact density**: New density option that hides avatars and previews for maximum information density (44px touch targets on mobile) +- **Security tooltips**: SPF, DKIM, and DMARC indicators now show plain-language explanations on hover +- **Resizable sidebars**: Drag the sidebar edge to resize (180-400px), with keyboard and touch support, persisted in settings +- **Sender info panel**: Click a sender's name to see their contact info, add to contacts, or search all their emails - **OAuth-only mode**: New `OAUTH_ONLY` env var hides the username/password form and only shows SSO login (#32) - **OAuth retry**: Added retry button when OAuth discovery fails, preventing dead-end login pages +### Improvements + +- Mobile/tablet layout transitions are now CSS-first — no more blink on orientation change +- More Actions dropdown works on touch devices (was hover-only) +- Touch-friendly context menu submenus (tap-to-expand instead of hover) +- Wide HTML emails are horizontally scrollable in iframe view + ## 1.1.4 (2026-03-16) ### Fixes diff --git a/README.md b/README.md index 49a0997..7c2bb7f 100644 --- a/README.md +++ b/README.md @@ -13,23 +13,28 @@ Stalwart is a mail server written in Rust with native JMAP support, not IMAP/SMT ### Email - Read, compose, reply, reply-all, and forward -- HTML rendering with DOMPurify sanitization +- HTML rendering with DOMPurify sanitization and sandboxed iframe for complex emails - Attachment upload and download - Draft auto-save with discard confirmation - Threading with inline expansion - Mark as read/unread, star/unstar - Archive and delete with configurable behavior -- Color tags/labels +- Color tags/labels with sidebar counts - Search with JMAP filter panel, search chips, cross-mailbox queries - Virtual scrolling for large lists +- Empty folder (one-click empty Junk/Trash with batch progress) +- Sender info panel (click sender name to view contact, add to contacts, search) +- API retry with exponential backoff for transient failures ### Interface - Three-pane layout with dark and light themes -- Responsive (desktop sidebar + mobile bottom tab bar) +- Responsive (desktop sidebar + mobile bottom tab bar + mobile action bar) - Keyboard shortcuts - Drag-and-drop email organization -- Right-click context menus +- Right-click context menus (long-press on touch devices) +- Extra-compact, compact, regular, and comfortable density options +- Resizable sidebar (drag, touch, keyboard) - Animations that respect `prefers-reduced-motion` - Infinite scroll pagination - Toast notifications with undo support @@ -107,7 +112,7 @@ Stalwart is a mail server written in Rust with native JMAP support, not IMAP/SMT - External content blocked by default - Trusted senders list for automatic image loading - HTML sanitization (DOMPurify) -- SPF/DKIM/DMARC status indicators +- SPF/DKIM/DMARC status indicators with plain-language tooltips - Session-based auth, no password storage by default - TOTP two-factor authentication - "Remember me" with AES-256-GCM encrypted httpOnly cookie (opt-in) diff --git a/ROADMAP.md b/ROADMAP.md index fab6bb0..7363882 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -203,6 +203,27 @@ This document tracks the development status and planned features for JMAP Webmai ### Email Display - [x] Proper email layout without horizontal scroll or clipping - [x] Blocked image container collapsing (no empty spaces in newsletters) +- [x] Sandboxed iframe rendering for rich HTML emails (CSS isolation from app) +- [x] Adaptive rendering: iframe for complex HTML, inline for plain text/simple HTML +- [x] SPF/DKIM/DMARC tooltips with plain-language security explanations +- [x] Expandable sender info panel (contact lookup, add-to-contacts, search sender) + +### Mobile & Touch +- [x] Bottom action bar for email actions (Reply, Reply All, Archive, Delete, More) +- [x] Long-press context menu with haptic feedback (300ms, cancels on scroll) +- [x] Touch-friendly context menu submenus (tap-to-expand on touch devices) +- [x] CSS-first responsive layout (instant orientation changes, no JS-driven blink) +- [x] Click-to-toggle more-actions dropdown (was hover-only) + +### Sidebar & Density +- [x] Tag counts section with color-coded tags and email counts +- [x] Empty folder option for Junk/Trash (batch delete with progress) +- [x] Extra-compact density option (28px rows, 44px on touch devices) +- [x] Resizable sidebar via drag handle (180-400px, keyboard accessible, persisted) + +### API Robustness +- [x] Exponential backoff retry for transient JMAP failures (429, 502, 503, 504) +- [x] Opt-out for blob downloads, uploads, and polling (no double-request risk) ### Testing - [x] Unit tests for validation utilities (57 tests) @@ -223,6 +244,8 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Unit tests for calendar participants (26 tests) - [x] Unit tests for template utilities (48 tests) - [x] Unit tests for OAuth PKCE and discovery (14 tests) +- [x] Unit tests for iframe rendering detection (12 tests) +- [x] Unit tests for API retry with backoff (9 tests) - [x] XSS attack vector testing - [x] Playwright E2E framework setup diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 8568f1b..65aa1b6 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -16,6 +16,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore } from "@/stores/ui-store"; +import { useContactStore } from "@/stores/contact-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { debug } from "@/lib/debug"; @@ -91,8 +92,11 @@ export default function Home() { clearSearchFilters, toggleAdvancedSearch, advancedSearch, + fetchTagCounts, } = useEmailStore(); + const contactStore = useContactStore(); + // Keyboard shortcuts handlers const keyboardHandlers = useMemo(() => ({ onNextEmail: () => { @@ -115,13 +119,7 @@ export default function Home() { // Email is already opened when selected }, onCloseEmail: () => { - selectEmail(null); - if (isMobile) { - setActiveView("list"); - } - if (isTablet) { - setTabletListVisible(true); - } + dismissViewer(); }, onReply: () => { if (selectedEmail) handleReply(); @@ -267,6 +265,9 @@ export default function Home() { await fetchEmails(client); } + // Fetch tag counts in the background + fetchTagCounts(client); + // Setup push notifications after successful data load try { // Register state change callback @@ -298,7 +299,7 @@ export default function Home() { client.closePushNotifications(); } }; - }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, handleStateChange, setPushConnected]); + }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); // Handle mark-as-read with delay based on settings useEffect(() => { @@ -367,6 +368,13 @@ export default function Home() { }; }, [isMobile, isTablet, sidebarOpen]); + // Reset tablet list visibility when crossing to desktop + useEffect(() => { + if (!isMobile && !isTablet) { + setTabletListVisible(true); + } + }, [isMobile, isTablet, setTabletListVisible]); + const handleEmailSend = async (data: { to: string[]; cc: string[]; @@ -417,12 +425,18 @@ export default function Home() { setShowComposer(true); }; + const dismissViewer = () => { + selectEmail(null); + if (isMobile) setActiveView("list"); + if (isTablet) setTabletListVisible(true); + }; + const handleDelete = async () => { if (!client || !selectedEmail) return; try { await deleteEmail(client, selectedEmail.id); - selectEmail(null); + dismissViewer(); } catch (error) { console.error("Failed to delete email:", error); } @@ -436,7 +450,7 @@ export default function Home() { if (archiveMailbox) { try { await moveToMailbox(client, selectedEmail.id, archiveMailbox.id); - selectEmail(null); + dismissViewer(); } catch (error) { console.error("Failed to archive email:", error); } @@ -655,11 +669,6 @@ export default function Home() { setActiveView("viewer"); } - // On tablet, hide the list to maximize viewer space - if (isTablet) { - setTabletListVisible(false); - } - // Fetch the full content try { // Find selected mailbox to determine accountId (for shared folders) @@ -670,7 +679,7 @@ export default function Home() { const fullEmail = await client.getEmail(email.id, accountId); if (fullEmail) { selectEmail(fullEmail); - // Mark-as-read logic is now handled by useEffect + if (isTablet) setTabletListVisible(false); } } catch (error) { console.error('Failed to fetch email content:', error); @@ -790,12 +799,12 @@ export default function Home() { "flex flex-col h-full bg-background border-r border-border", // Mobile: full width, hidden when viewing email "max-md:flex-1 max-md:border-r-0", - isMobile && activeView !== "list" && "max-md:hidden", - // Tablet/Desktop: fixed width with collapse animation - "md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm", - "transition-all duration-200 ease-out", - // Tablet: collapse when email selected - isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0" + activeView !== "list" && "max-md:hidden", + // Tablet: full width when no email, fixed width when viewing + !selectedEmail ? "md:flex-1 md:border-r-0" : "md:w-80 lg:w-96 md:flex-shrink-0", + "md:shadow-sm", + // Collapse list when viewing email on tablet (tabletListVisible only toggled in tablet range) + selectedEmail && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0" )} > {/* Mobile Header for List View */} @@ -886,9 +895,11 @@ export default function Home() { "flex flex-col h-full bg-background", // Mobile: full screen overlay when active "max-md:fixed max-md:inset-0 max-md:z-30", - isMobile && activeView !== "viewer" && "max-md:hidden", - // Tablet/Desktop: flex grow, min-w-0 allows truncation of long subjects - "md:flex-1 md:min-w-0 md:relative" + activeView !== "viewer" && "max-md:hidden", + // Tablet/Desktop: flex grow + "md:flex-1 md:min-w-0 md:relative", + // Hide viewer when no email selected (list takes full width) + !selectedEmail && "max-lg:hidden" )} > {/* Mobile Conversation View - shown when thread is selected on mobile */} @@ -943,6 +954,30 @@ export default function Home() { selectEmail(null); }} onShowShortcuts={() => setShowShortcutsModal(true)} + onSearchSender={(email) => { + const query = `from:${email}`; + setSearchQuery(query); + if (client) { + searchEmails(client, query); + } + }} + onAddContact={(name, email) => { + if (client && contactStore.supportsSync) { + contactStore.createContact(client, { + kind: 'individual', + name: name ? { components: [{ kind: 'given', value: name }], isOrdered: true } : undefined, + emails: { email0: { address: email } }, + }); + } else { + contactStore.addLocalContact({ + id: `local-${crypto.randomUUID()}`, + addressBookIds: {}, + kind: 'individual', + name: name ? { components: [{ kind: 'given', value: name }], isOrdered: true } : undefined, + emails: { email0: { address: email } }, + }); + } + }} currentUserEmail={client?.["username"]} currentUserName={client?.["username"]?.split("@")[0]} currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} diff --git a/app/globals.css b/app/globals.css index 0ccda18..691192f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -387,6 +387,13 @@ body { animation: slide-in-from-left 0.3s ease-out; } +/* Extra-compact density: enforce minimum touch target on touch devices */ +@media (pointer: coarse) { + [data-density="extra-compact"] .email-list-item { + min-height: 44px; + } +} + /* Reduced motion: respect user OS preference */ @media (prefers-reduced-motion: reduce) { *, diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 58f57ce..2d0515e 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -10,6 +10,7 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailDrag } from "@/hooks/use-email-drag"; +import { useLongPress } from "@/hooks/use-long-press"; import { EmailIdentityBadge } from "./email-identity-badge"; interface EmailListItemProps { @@ -45,6 +46,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email const t = useTranslations('email_viewer'); const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore(); const showPreview = useSettingsStore((state) => state.showPreview); + const listDensity = useSettingsStore((state) => state.listDensity); + const isExtraCompact = listDensity === 'extra-compact'; const { identities } = useAuthStore(); const isChecked = selectedEmailIds.has(email.id); const isUnread = !email.keywords?.$seen; @@ -68,11 +71,28 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email onContextMenu?.(e, email); }; + const longPressHandlers = useLongPress({ + onLongPress: (e) => { + if (onContextMenu) { + const touch = e.touches?.[0] || e.changedTouches?.[0]; + if (touch) { + const syntheticEvent = { + preventDefault: () => {}, + clientX: touch.clientX, + clientY: touch.clientY, + } as unknown as React.MouseEvent; + onContextMenu(syntheticEvent, email); + } + } + }, + }); + return (
+ {!isExtraCompact && ( + + )} {/* Content */}
@@ -151,7 +173,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email )} - {email.hasAttachment && ( + {!isExtraCompact && email.hasAttachment && ( )}
@@ -176,8 +198,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email {email.subject || t('no_subject')}
- {/* Third Line: Preview (controlled by showPreview setting) */} - {showPreview && ( + {/* Third Line: Preview (controlled by showPreview setting, hidden in extra-compact) */} + {showPreview && !isExtraCompact && (

state.showPreview); const estimateSize = useCallback(() => { - const base = { compact: 72, regular: 88, comfortable: 104 }[listDensity]; - return showPreview ? base + 40 : base; + const base = { 'extra-compact': 44, compact: 72, regular: 88, comfortable: 104 }[listDensity]; + return listDensity === 'extra-compact' ? base : (showPreview ? base + 40 : base); }, [listDensity, showPreview]); const virtualizer = useVirtualizer({ diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0c8846f..c60ac4b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3,7 +3,8 @@ import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; import { Email } from "@/lib/jmap/types"; -import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; +import { hasRichFormatting, needsIframeRendering, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; +import { SandboxedEmailFrame } from "./sandboxed-email-frame"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn } from "@/lib/utils"; @@ -57,9 +58,11 @@ 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 { MobileActionBar } from "./mobile-action-bar"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { findCalendarAttachment } from "@/lib/calendar-invitation"; +import { SenderInfoPanel } from "./sender-info-panel"; interface EmailViewerProps { email: Email | null; @@ -78,6 +81,8 @@ interface EmailViewerProps { onUndoSpam?: () => void; onBack?: () => void; onShowShortcuts?: () => void; + onSearchSender?: (email: string) => void; + onAddContact?: (name: string, email: string) => void; currentUserEmail?: string; currentUserName?: string; currentMailboxRole?: string; @@ -173,6 +178,8 @@ export function EmailViewer({ onUndoSpam, onBack, onShowShortcuts, + onSearchSender, + onAddContact, currentUserEmail, currentUserName, currentMailboxRole, @@ -200,7 +207,7 @@ export function EmailViewer({ ]; // Tablet list visibility - const { isTablet } = useDeviceDetection(); + const { isMobile, isTablet } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); const { identities } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); @@ -211,6 +218,8 @@ export function EmailViewer({ const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); const [showSourceModal, setShowSourceModal] = useState(false); + const [showSenderInfo, setShowSenderInfo] = useState(false); + const [showMoreActions, setShowMoreActions] = useState(false); const currentColor = getCurrentColor(email?.keywords); const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState>( () => { @@ -237,6 +246,8 @@ export function EmailViewer({ setQuickReplyText(""); setIsQuickReplyFocused(false); setShowSourceModal(false); + setShowSenderInfo(false); + setShowMoreActions(false); }, [email?.id, externalContentPolicy]); // Generate email source for viewing @@ -503,7 +514,8 @@ export function EmailViewer({ return { html: cleanHtml, - isHtml: true + isHtml: true, + useIframe: needsIframeRendering(htmlContent), }; } @@ -859,25 +871,29 @@ export function EmailViewer({ {/* More Actions Dropdown */} -

+
-
+ {showMoreActions && ( + <> +
setShowMoreActions(false)} onKeyDown={(e) => e.key === 'Escape' && setShowMoreActions(false)} role="presentation" /> +
{onShowShortcuts && ( )} -
+
+ + )}
@@ -924,21 +942,31 @@ export function EmailViewer({ {/* Sender Info - Desktop only (hidden on mobile/tablet, they see it in scrollable content) */} -
-
- +
+
+
{/* Sender line with compact badges */}
- +
@@ -1008,94 +1036,109 @@ export function EmailViewer({
{/* SPF Check */} {email.authenticationResults.spf && ( -
-
- {getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' && - } - {getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' && - } - {getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' && - } - {getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' && - } -
-
SPF
-
- {email.authenticationResults.spf.result} +
+
+
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' && + } + {getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' && + } + {getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' && + } + {getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' && + } +
+
SPF
+
+ {email.authenticationResults.spf.result} +
+ {email.authenticationResults.spf.domain && ( +
+ {email.authenticationResults.spf.domain} +
+ )} +
+
+ {t(`security.tooltip.spf_${email.authenticationResults.spf.result}`)}
- {email.authenticationResults.spf.domain && ( -
- {email.authenticationResults.spf.domain} -
- )}
)} {/* DKIM Check */} {email.authenticationResults.dkim && ( -
-
- {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' && - } - {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' && - } - {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' && - } - {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' && - } -
-
DKIM
-
- {email.authenticationResults.dkim.result} +
+
+
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' && + } + {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' && + } + {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' && + } + {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' && + } +
+
DKIM
+
+ {email.authenticationResults.dkim.result} +
+ {email.authenticationResults.dkim.domain && ( +
+ {email.authenticationResults.dkim.domain} +
+ )} +
+
+ {t(`security.tooltip.dkim_${email.authenticationResults.dkim.result}`)}
- {email.authenticationResults.dkim.domain && ( -
- {email.authenticationResults.dkim.domain} -
- )}
)} {/* DMARC Check */} {email.authenticationResults.dmarc && ( -
-
- {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' && - } - {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' && - } - {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' && - } - {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' && - } -
-
DMARC
-
- {email.authenticationResults.dmarc.result} +
+
+
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' && + } + {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' && + } + {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' && + } + {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' && + } +
+
DMARC
+
+ {email.authenticationResults.dmarc.result} +
+ {email.authenticationResults.dmarc.policy && ( +
+ Policy: {email.authenticationResults.dmarc.policy} +
+ )} +
+
+ {t(`security.tooltip.dmarc_${email.authenticationResults.dmarc.result}`)}
- {email.authenticationResults.dmarc.policy && ( -
- Policy: {email.authenticationResults.dmarc.policy} -
- )}
)} @@ -1274,6 +1317,13 @@ export function EmailViewer({
+ {showSenderInfo && sender && ( + onSearchSender?.(email)} + onAddContact={(name, email) => onAddContact?.(name, email)} + /> + )}
{/* Email Content Area */} @@ -1281,18 +1331,28 @@ export function EmailViewer({ {/* Mobile/Tablet Sender Info - scrolls with content */}
- +
{/* Mobile 2-line layout */}
- +
@@ -1323,6 +1383,13 @@ export function EmailViewer({ )}
+ {showSenderInfo && sender && ( + onSearchSender?.(email)} + onAddContact={(name, email) => onAddContact?.(name, email)} + /> + )}
{/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */} @@ -1476,7 +1543,9 @@ export function EmailViewer({ {/* Email Body */}
- {emailContent.isHtml ? ( + {emailContent.isHtml && emailContent.useIframe ? ( + + ) : emailContent.isHtml ? (
+ {/* Mobile bottom action bar */} + {email && (isMobile || isTablet) && ( + onReply?.()} + onReplyAll={() => onReplyAll?.()} + onArchive={() => onArchive?.()} + onDelete={() => onDelete?.()} + onForward={() => onForward?.()} + onStar={() => onToggleStar?.()} + onMarkUnread={() => email && onMarkAsRead?.(email.id, false)} + onSpam={() => onMarkAsSpam?.()} + /> + )} + {/* Email Source Modal */} {showSourceModal && email && (
void; + onReplyAll: () => void; + onArchive: () => void; + onDelete: () => void; + onForward: () => void; + onStar: () => void; + onMarkUnread: () => void; + onSpam: () => void; +} + +export function MobileActionBar({ + onReply, onReplyAll, onArchive, onDelete, + onForward, onStar, onMarkUnread, onSpam, +}: MobileActionBarProps) { + const t = useTranslations('email_viewer.mobile_actions'); + const [showMore, setShowMore] = useState(false); + + return ( + <> + {showMore && ( +
setShowMore(false)} + onKeyDown={(e) => e.key === 'Escape' && setShowMore(false)} + /> + )} + + {showMore && ( +
+
+ {[ + { icon: Forward, label: t('forward'), action: onForward }, + { icon: Star, label: t('star'), action: onStar }, + { icon: MailOpen, label: t('mark_unread'), action: onMarkUnread }, + { icon: ShieldAlert, label: t('spam'), action: onSpam }, + ].map(({ icon: Icon, label, action }) => ( + + ))} +
+ +
+ )} + +
+ {[ + { icon: Reply, label: t('reply'), action: onReply }, + { icon: ReplyAll, label: t('reply_all'), action: onReplyAll }, + { icon: Archive, label: t('archive'), action: onArchive }, + { icon: Trash2, label: t('delete'), action: onDelete }, + { icon: MoreHorizontal, label: t('more'), action: () => setShowMore(prev => !prev) }, + ].map(({ icon: Icon, label, action }) => ( + + ))} +
+ + ); +} diff --git a/components/email/sandboxed-email-frame.tsx b/components/email/sandboxed-email-frame.tsx new file mode 100644 index 0000000..ed54e29 --- /dev/null +++ b/components/email/sandboxed-email-frame.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useRef, useEffect, useCallback } from "react"; +import { generateIframeStylesheet } from "@/lib/color-transform"; + +interface SandboxedEmailFrameProps { + html: string; + className?: string; +} + +function wrapHtmlForIframe(sanitizedHtml: string): string { + return ` + + + + + ${generateIframeStylesheet()} + +${sanitizedHtml} +`; +} + +export function SandboxedEmailFrame({ html, className }: SandboxedEmailFrameProps) { + const iframeRef = useRef(null); + + const updateHeight = useCallback(() => { + const iframe = iframeRef.current; + if (!iframe?.contentDocument?.body) return; + const height = iframe.contentDocument.body.scrollHeight; + iframe.style.height = `${height + 16}px`; + }, []); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + + let observer: ResizeObserver | null = null; + + const handleLoad = () => { + updateHeight(); + + const doc = iframe.contentDocument; + if (!doc?.body) return; + + observer = new ResizeObserver(updateHeight); + observer.observe(doc.body); + + doc.addEventListener('click', (e: MouseEvent) => { + const target = e.target as HTMLElement; + const anchor = target.closest('a'); + if (anchor?.href) { + e.preventDefault(); + window.open(anchor.href, '_blank', 'noopener,noreferrer'); + } + }); + }; + + iframe.addEventListener('load', handleLoad); + return () => { + iframe.removeEventListener('load', handleLoad); + observer?.disconnect(); + }; + }, [html, updateHeight]); + + return ( +