feat: v1.2.0 — sandboxed email rendering, mobile UX, sidebar polish, API retry

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
This commit is contained in:
Matthieu MALVACHE
2026-03-16 16:29:47 +01:00
committed by Matthieu MALVACHE
parent d56e05c146
commit ede603563e
33 changed files with 1869 additions and 178 deletions
+17
View File
@@ -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
+10 -5
View File
@@ -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)
+23
View File
@@ -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
+60 -25
View File
@@ -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}
+7
View File
@@ -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) {
*,
+32 -10
View File
@@ -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 (
<div
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
"email-list-item relative group cursor-pointer transition-all duration-200 border-b border-border",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
@@ -121,12 +141,14 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
)}
{/* Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{!isExtraCompact && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
)}
{/* Content */}
<div className="flex-1 min-w-0">
@@ -151,7 +173,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{email.hasAttachment && (
{!isExtraCompact && email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
@@ -176,8 +198,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && (
{/* Third Line: Preview (controlled by showPreview setting, hidden in extra-compact) */}
{showPreview && !isExtraCompact && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
+2 -2
View File
@@ -100,8 +100,8 @@ export function EmailList({
const showPreview = useSettingsStore((state) => 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({
+181 -98
View File
@@ -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<Set<string>>(
() => {
@@ -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({
</div>
{/* More Actions Dropdown */}
<div className="relative group">
<div className="relative">
<Button
variant="ghost"
size="icon"
className="h-10 w-10 lg:h-8 lg:w-8 hover:bg-muted"
title={t('more_actions')}
onClick={() => setShowMoreActions(prev => !prev)}
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
</Button>
<div className="absolute right-0 top-full mt-1 w-44 bg-background rounded-md shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-10">
{showMoreActions && (
<>
<div className="fixed inset-0 z-[9]" onClick={() => setShowMoreActions(false)} onKeyDown={(e) => e.key === 'Escape' && setShowMoreActions(false)} role="presentation" />
<div className="absolute right-0 top-full mt-1 w-44 bg-background rounded-md shadow-lg border border-border z-10" role="menu">
<button
onClick={() => setShowSourceModal(true)}
onClick={() => { setShowSourceModal(true); setShowMoreActions(false); }}
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
>
<Code className="w-4 h-4" />
{t('view_source')}
</button>
<button
onClick={() => window.print()}
onClick={() => { window.print(); setShowMoreActions(false); }}
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
>
<Printer className="w-4 h-4" />
@@ -885,7 +901,7 @@ export function EmailViewer({
</button>
{onShowShortcuts && (
<button
onClick={onShowShortcuts}
onClick={() => { onShowShortcuts(); setShowMoreActions(false); }}
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
>
<Keyboard className="w-4 h-4" />
@@ -897,7 +913,7 @@ export function EmailViewer({
{/* Spam action - contextual */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setShowMoreActions(false); }}
className={cn(
"w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2",
isInJunkFolder ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"
@@ -916,7 +932,9 @@ export function EmailViewer({
)}
</button>
)}
</div>
</div>
</>
)}
</div>
</div>
</div>
@@ -924,21 +942,31 @@ export function EmailViewer({
</div>
{/* Sender Info - Desktop only (hidden on mobile/tablet, they see it in scrollable content) */}
<div className="hidden lg:block bg-background border-b border-border px-6 py-4">
<div className="flex items-start gap-4">
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-12 h-12"
/>
<div className="hidden lg:block bg-background border-b border-border">
<div className="flex items-start gap-4 px-6 py-4">
<button
type="button"
onClick={() => setShowSenderInfo(!showSenderInfo)}
className="cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0"
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-12 h-12"
/>
</button>
<div className="flex-1 min-w-0">
{/* Sender line with compact badges */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
<button
type="button"
onClick={() => setShowSenderInfo(!showSenderInfo)}
className="font-semibold text-foreground hover:text-primary transition-colors cursor-pointer"
>
{sender?.name || sender?.email || t('unknown_sender')}
</span>
</button>
<EmailIdentityBadge email={email} identities={identities} />
</div>
@@ -1008,94 +1036,109 @@ export function EmailViewer({
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{/* SPF Check */}
{email.authenticationResults.spf && (
<div className={cn(
"px-3 py-2 rounded-md",
getSecurityStatus(email.authenticationResults.spf.result).bgColor,
getSecurityStatus(email.authenticationResults.spf.result).borderColor
)}>
<div className="flex items-center gap-2">
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' &&
<Check className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' &&
<X className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' &&
<AlertTriangle className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' &&
<Minus className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
<div>
<div className="text-xs font-medium text-gray-900 dark:text-gray-100">SPF</div>
<div className={cn("text-xs capitalize", getSecurityStatus(email.authenticationResults.spf.result).color)}>
{email.authenticationResults.spf.result}
<div className="group/spf relative">
<div className={cn(
"px-3 py-2 rounded-md",
getSecurityStatus(email.authenticationResults.spf.result).bgColor,
getSecurityStatus(email.authenticationResults.spf.result).borderColor
)}>
<div className="flex items-center gap-2">
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' &&
<Check className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' &&
<X className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' &&
<AlertTriangle className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
{getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' &&
<Minus className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.spf.result).color)} />}
<div>
<div className="text-xs font-medium text-gray-900 dark:text-gray-100">SPF</div>
<div className={cn("text-xs capitalize", getSecurityStatus(email.authenticationResults.spf.result).color)}>
{email.authenticationResults.spf.result}
</div>
</div>
</div>
{email.authenticationResults.spf.domain && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate" title={email.authenticationResults.spf.domain}>
{email.authenticationResults.spf.domain}
</div>
)}
</div>
<div className="absolute invisible group-hover/spf:visible bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 text-xs text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded-lg shadow-lg max-w-xs z-50 whitespace-normal pointer-events-none">
{t(`security.tooltip.spf_${email.authenticationResults.spf.result}`)}
</div>
{email.authenticationResults.spf.domain && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate" title={email.authenticationResults.spf.domain}>
{email.authenticationResults.spf.domain}
</div>
)}
</div>
)}
{/* DKIM Check */}
{email.authenticationResults.dkim && (
<div className={cn(
"px-3 py-2 rounded-md",
getSecurityStatus(email.authenticationResults.dkim.result).bgColor,
getSecurityStatus(email.authenticationResults.dkim.result).borderColor
)}>
<div className="flex items-center gap-2">
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' &&
<Check className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' &&
<X className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' &&
<AlertTriangle className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' &&
<Minus className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
<div>
<div className="text-xs font-medium text-gray-900 dark:text-gray-100">DKIM</div>
<div className={cn("text-xs capitalize", getSecurityStatus(email.authenticationResults.dkim.result).color)}>
{email.authenticationResults.dkim.result}
<div className="group/dkim relative">
<div className={cn(
"px-3 py-2 rounded-md",
getSecurityStatus(email.authenticationResults.dkim.result).bgColor,
getSecurityStatus(email.authenticationResults.dkim.result).borderColor
)}>
<div className="flex items-center gap-2">
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' &&
<Check className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' &&
<X className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' &&
<AlertTriangle className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
{getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' &&
<Minus className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dkim.result).color)} />}
<div>
<div className="text-xs font-medium text-gray-900 dark:text-gray-100">DKIM</div>
<div className={cn("text-xs capitalize", getSecurityStatus(email.authenticationResults.dkim.result).color)}>
{email.authenticationResults.dkim.result}
</div>
</div>
</div>
{email.authenticationResults.dkim.domain && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate" title={email.authenticationResults.dkim.domain}>
{email.authenticationResults.dkim.domain}
</div>
)}
</div>
<div className="absolute invisible group-hover/dkim:visible bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 text-xs text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded-lg shadow-lg max-w-xs z-50 whitespace-normal pointer-events-none">
{t(`security.tooltip.dkim_${email.authenticationResults.dkim.result}`)}
</div>
{email.authenticationResults.dkim.domain && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate" title={email.authenticationResults.dkim.domain}>
{email.authenticationResults.dkim.domain}
</div>
)}
</div>
)}
{/* DMARC Check */}
{email.authenticationResults.dmarc && (
<div className={cn(
"px-3 py-2 rounded-md",
getSecurityStatus(email.authenticationResults.dmarc.result).bgColor,
getSecurityStatus(email.authenticationResults.dmarc.result).borderColor
)}>
<div className="flex items-center gap-2">
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' &&
<Check className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' &&
<X className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' &&
<AlertTriangle className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' &&
<Minus className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
<div>
<div className="text-xs font-medium text-gray-900 dark:text-gray-100">DMARC</div>
<div className={cn("text-xs capitalize", getSecurityStatus(email.authenticationResults.dmarc.result).color)}>
{email.authenticationResults.dmarc.result}
<div className="group/dmarc relative">
<div className={cn(
"px-3 py-2 rounded-md",
getSecurityStatus(email.authenticationResults.dmarc.result).bgColor,
getSecurityStatus(email.authenticationResults.dmarc.result).borderColor
)}>
<div className="flex items-center gap-2">
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' &&
<Check className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' &&
<X className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' &&
<AlertTriangle className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
{getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' &&
<Minus className={cn("w-4 h-4", getSecurityStatus(email.authenticationResults.dmarc.result).color)} />}
<div>
<div className="text-xs font-medium text-gray-900 dark:text-gray-100">DMARC</div>
<div className={cn("text-xs capitalize", getSecurityStatus(email.authenticationResults.dmarc.result).color)}>
{email.authenticationResults.dmarc.result}
</div>
</div>
</div>
{email.authenticationResults.dmarc.policy && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Policy: {email.authenticationResults.dmarc.policy}
</div>
)}
</div>
<div className="absolute invisible group-hover/dmarc:visible bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 text-xs text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded-lg shadow-lg max-w-xs z-50 whitespace-normal pointer-events-none">
{t(`security.tooltip.dmarc_${email.authenticationResults.dmarc.result}`)}
</div>
{email.authenticationResults.dmarc.policy && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Policy: {email.authenticationResults.dmarc.policy}
</div>
)}
</div>
)}
@@ -1274,6 +1317,13 @@ export function EmailViewer({
</div>
</div>
{showSenderInfo && sender && (
<SenderInfoPanel
sender={sender}
onSearch={(email) => onSearchSender?.(email)}
onAddContact={(name, email) => onAddContact?.(name, email)}
/>
)}
</div>
{/* Email Content Area */}
@@ -1281,18 +1331,28 @@ export function EmailViewer({
{/* Mobile/Tablet Sender Info - scrolls with content */}
<div className="lg:hidden bg-background border-b border-border px-4 py-3">
<div className="flex items-start gap-3">
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10"
/>
<button
type="button"
onClick={() => setShowSenderInfo(!showSenderInfo)}
className="cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0"
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10"
/>
</button>
<div className="flex-1 min-w-0">
{/* Mobile 2-line layout */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-foreground">
<button
type="button"
onClick={() => setShowSenderInfo(!showSenderInfo)}
className="text-sm font-semibold text-foreground hover:text-primary transition-colors cursor-pointer"
>
{sender?.name || sender?.email || t('unknown_sender')}
</span>
</button>
<EmailIdentityBadge email={email} identities={identities} />
</div>
<div className="mt-1 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
@@ -1323,6 +1383,13 @@ export function EmailViewer({
)}
</div>
</div>
{showSenderInfo && sender && (
<SenderInfoPanel
sender={sender}
onSearch={(email) => onSearchSender?.(email)}
onAddContact={(name, email) => onAddContact?.(name, email)}
/>
)}
</div>
{/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */}
@@ -1476,7 +1543,9 @@ export function EmailViewer({
{/* Email Body */}
<div className="bg-background rounded-lg shadow-sm border border-border overflow-x-auto">
<div className="email-content-wrapper p-6">
{emailContent.isHtml ? (
{emailContent.isHtml && emailContent.useIframe ? (
<SandboxedEmailFrame html={emailContent.html} className="w-full" />
) : emailContent.isHtml ? (
<div
className="email-content prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: emailContent.html }}
@@ -1600,6 +1669,20 @@ export function EmailViewer({
</div>
</div>
{/* Mobile bottom action bar */}
{email && (isMobile || isTablet) && (
<MobileActionBar
onReply={() => onReply?.()}
onReplyAll={() => onReplyAll?.()}
onArchive={() => onArchive?.()}
onDelete={() => onDelete?.()}
onForward={() => onForward?.()}
onStar={() => onToggleStar?.()}
onMarkUnread={() => email && onMarkAsRead?.(email.id, false)}
onSpam={() => onMarkAsSpam?.()}
/>
)}
{/* Email Source Modal */}
{showSourceModal && email && (
<div
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Reply, ReplyAll, Archive, Trash2, MoreHorizontal, Forward, Star, MailOpen, ShieldAlert, X } from "lucide-react";
interface MobileActionBarProps {
onReply: () => 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 && (
<div
className="fixed inset-0 z-40 bg-black/30"
onClick={() => setShowMore(false)}
onKeyDown={(e) => e.key === 'Escape' && setShowMore(false)}
/>
)}
{showMore && (
<div
className="fixed bottom-14 left-0 right-0 z-50 bg-background border-t border-border rounded-t-xl shadow-lg"
role="menu"
aria-label={t('more')}
>
<div className="p-2 space-y-1">
{[
{ 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 }) => (
<button
key={label}
className="flex items-center gap-3 w-full px-4 py-3 text-sm rounded-lg hover:bg-accent text-foreground"
onClick={() => { action(); setShowMore(false); }}
role="menuitem"
>
<Icon className="h-5 w-5 text-muted-foreground" />
{label}
</button>
))}
</div>
<button
className="flex items-center justify-center w-full py-3 border-t border-border text-muted-foreground"
onClick={() => setShowMore(false)}
>
<X className="h-5 w-5" />
</button>
</div>
)}
<div
className="fixed bottom-0 left-0 right-0 z-30 lg:hidden bg-background border-t border-border h-14 flex items-center justify-around px-2"
role="toolbar"
aria-label="Email actions"
>
{[
{ 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 }) => (
<button
key={label}
className="flex flex-col items-center justify-center gap-0.5 px-3 py-1 text-muted-foreground hover:text-foreground"
onClick={action}
aria-label={label}
>
<Icon className="h-5 w-5" />
<span className="text-[10px]">{label}</span>
</button>
))}
</div>
</>
);
}
@@ -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 `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${generateIframeStylesheet()}
</head>
<body>${sanitizedHtml}</body>
</html>`;
}
export function SandboxedEmailFrame({ html, className }: SandboxedEmailFrameProps) {
const iframeRef = useRef<HTMLIFrameElement>(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 (
<iframe
ref={iframeRef}
sandbox="allow-same-origin"
srcDoc={wrapHtmlForIframe(html)}
className={className}
style={{
width: '100%',
border: 'none',
overflow: 'hidden',
minHeight: '100px',
}}
title="Email content"
/>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useMemo } from "react";
import type { EmailAddress } from "@/lib/jmap/types";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useTranslations } from "next-intl";
import { UserPlus, Search } from "lucide-react";
interface SenderInfoPanelProps {
sender: EmailAddress;
onSearch: (email: string) => void;
onAddContact: (name: string, email: string) => void;
}
export function SenderInfoPanel({ sender, onSearch, onAddContact }: SenderInfoPanelProps) {
const t = useTranslations("email_viewer.sender_info");
const contacts = useContactStore((state) => state.contacts);
const matchedContact = useMemo(() => {
const lowerEmail = sender.email.toLowerCase();
return contacts.find((c) => {
if (c.kind === "group" || !c.emails) return false;
return Object.values(c.emails).some(
(e) => e.address?.toLowerCase() === lowerEmail
);
});
}, [contacts, sender.email]);
const orgName = useMemo(() => {
if (!matchedContact?.organizations) return null;
const org = Object.values(matchedContact.organizations)[0];
return org?.name || null;
}, [matchedContact]);
return (
<div className="animate-in slide-in-from-top-2 duration-200 border-t border-border bg-muted/30 px-6 py-4">
<div className="flex items-start gap-4">
<Avatar
name={sender.name}
email={sender.email}
size="lg"
className="w-14 h-14 text-base shadow-md"
/>
<div className="flex-1 min-w-0 space-y-2">
<div>
<div className="font-semibold text-foreground text-base">
{sender.name || sender.email}
</div>
{sender.name && (
<div className="text-sm text-muted-foreground truncate">
{sender.email}
</div>
)}
</div>
{matchedContact ? (
<div className="text-sm text-muted-foreground">
{orgName && (
<span>{orgName}</span>
)}
{!orgName && (
<span>{getContactDisplayName(matchedContact)}</span>
)}
</div>
) : (
<div className="text-sm text-muted-foreground italic">
{t("no_contact")}
</div>
)}
<div className="flex items-center gap-2 flex-wrap pt-1">
{!matchedContact && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => onAddContact(sender.name || "", sender.email)}
>
<UserPlus className="w-3.5 h-3.5 mr-1.5" />
{t("add_to_contacts")}
</Button>
)}
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => onSearch(sender.email)}
>
<Search className="w-3.5 h-3.5 mr-1.5" />
{t("view_all_emails")}
</Button>
</div>
</div>
</div>
</div>
);
}
+17 -12
View File
@@ -3,7 +3,8 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } 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 { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
import { Avatar } from "@/components/ui/avatar";
@@ -332,7 +333,7 @@ function EmailCard({
finalHtml = collapseBlockedImageContainers(sanitized);
}
return { html: finalHtml, isHtml: true };
return { html: finalHtml, isHtml: true, useIframe: needsIframeRendering(htmlContent) };
}
// Plain text fallback
@@ -447,16 +448,20 @@ function EmailCard({
{/* Email Body */}
<div className="px-4 py-4">
<div
className={cn(
"prose prose-sm max-w-none dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
/>
{emailContent.isHtml && emailContent.useIframe ? (
<SandboxedEmailFrame html={emailContent.html} className="w-full" />
) : (
<div
className={cn(
"prose prose-sm max-w-none dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
dangerouslySetInnerHTML={{ __html: emailContent.html }}
/>
)}
</div>
{/* Attachments */}
+226 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { Button } from "@/components/ui/button";
@@ -26,14 +26,18 @@ import {
SlidersHorizontal,
Settings,
X,
Tag,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { activeFilterCount } from "@/lib/jmap/search-utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useVacationStore } from "@/stores/vacation-store";
import { useResizeHandle } from "@/hooks/use-resize-handle";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
@@ -86,6 +90,7 @@ function MailboxTreeItem({
expandedFolders,
onMailboxSelect,
onToggleExpand,
onMailboxContextMenu,
isCollapsed,
}: {
node: MailboxNode;
@@ -93,6 +98,7 @@ function MailboxTreeItem({
expandedFolders: Set<string>;
onMailboxSelect?: (id: string) => void;
onToggleExpand: (id: string) => void;
onMailboxContextMenu?: (e: React.MouseEvent, mailbox: Mailbox) => void;
isCollapsed: boolean;
}) {
const t = useTranslations('sidebar');
@@ -128,6 +134,7 @@ function MailboxTreeItem({
<>
<div
{...(globalDragging ? dropHandlers : {})}
onContextMenu={(e) => onMailboxContextMenu?.(e, node)}
className={cn(
"group w-full flex items-center px-2 py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200",
isVirtualNode
@@ -209,6 +216,7 @@ function MailboxTreeItem({
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={onToggleExpand}
onMailboxContextMenu={onMailboxContextMenu}
isCollapsed={isCollapsed}
/>
))}
@@ -268,6 +276,115 @@ function AdvancedSearchToggle() {
);
}
const tagDotColors: Record<string, string> = {
red: "bg-red-500",
orange: "bg-orange-500",
yellow: "bg-yellow-500",
green: "bg-green-500",
blue: "bg-blue-500",
purple: "bg-purple-500",
pink: "bg-pink-500",
};
function TagsSection({
isCollapsed,
onSearch,
}: {
isCollapsed: boolean;
onSearch?: (query: string) => void;
}) {
const t = useTranslations("sidebar");
const { tagCounts } = useEmailStore();
const [expanded, setExpanded] = useState(true);
const tags = Object.entries(tagCounts);
if (tags.length === 0 || isCollapsed) return null;
return (
<div className="border-t border-border">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center w-full px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider hover:bg-muted transition-colors"
>
<Tag className="w-3 h-3 mr-2" />
<span className="flex-1 text-left">{t("tags.title")}</span>
{expanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
{expanded && (
<div className="py-1">
{tags.map(([color, count]) => (
<button
key={color}
onClick={() => onSearch?.(`keyword:$color:${color}`)}
className="flex items-center w-full px-4 py-1.5 text-sm hover:bg-muted transition-colors text-foreground"
>
<span
className={cn(
"w-2.5 h-2.5 rounded-full mr-2.5 flex-shrink-0",
tagDotColors[color] || "bg-gray-500"
)}
/>
<span className="flex-1 text-left capitalize">{color}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{count}
</span>
</button>
))}
</div>
)}
</div>
);
}
function EmptyFolderConfirmDialog({
mailbox,
onConfirm,
onCancel,
}: {
mailbox: { name: string; totalEmails: number };
onConfirm: () => void;
onCancel: () => void;
}) {
const t = useTranslations("sidebar");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={onCancel}
onKeyDown={(e) => e.key === "Escape" && onCancel()}
/>
<div className="relative bg-background rounded-lg shadow-xl p-6 max-w-sm mx-4 border border-border">
<h3 className="text-lg font-semibold mb-2">{t("empty_folder.title")}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t("empty_folder.confirm", {
count: mailbox.totalEmails,
folder: mailbox.name,
})}
</p>
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="px-4 py-2 text-sm rounded-md border border-border hover:bg-muted transition-colors"
>
{t("empty_folder.cancel")}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
>
{t("empty_folder.title")}
</button>
</div>
</div>
</div>
);
}
function StorageQuota({ quota, isCollapsed }: { quota: { used: number; total: number } | null; isCollapsed: boolean }) {
const t = useTranslations('sidebar');
@@ -322,7 +439,28 @@ export function Sidebar({
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; mailbox: Mailbox } | null>(null);
const [emptyFolderTarget, setEmptyFolderTarget] = useState<Mailbox | null>(null);
const t = useTranslations('sidebar');
const { client } = useAuthStore();
const { emptyFolder } = useEmailStore();
const { sidebarWidth, updateSetting } = useSettingsStore();
const handleSidebarResize = useCallback((width: number) => {
document.documentElement.style.setProperty('--sidebar-width', `${width}px`);
}, []);
const handleSidebarResizeEnd = useCallback((width: number) => {
updateSetting('sidebarWidth', width);
}, [updateSetting]);
const resizeHandle = useResizeHandle({
min: 180,
max: 400,
initial: sidebarWidth,
onResize: handleSidebarResize,
onResizeEnd: handleSidebarResizeEnd,
});
useEffect(() => {
setSearchQuery(activeSearchQuery);
@@ -368,6 +506,32 @@ export function Sidebar({
}
};
const handleMailboxContextMenu = useCallback((e: React.MouseEvent, mailbox: Mailbox) => {
if (mailbox.role !== "trash" && mailbox.role !== "junk") return;
if (!mailbox.totalEmails || mailbox.totalEmails <= 0) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, mailbox });
}, []);
const handleEmptyFolder = useCallback(async () => {
if (!emptyFolderTarget || !client) return;
const folderName = emptyFolderTarget.name;
const totalCount = emptyFolderTarget.totalEmails || 0;
const targetId = emptyFolderTarget.id;
setEmptyFolderTarget(null);
toast.info(t("empty_folder.title"), t("empty_folder.progress", { deleted: 0, total: totalCount }));
try {
await emptyFolder(client, targetId);
toast.success(t("empty_folder.title"), t("empty_folder.success"));
} catch (error) {
const match = error instanceof Error && error.message.match(/Deleted (\d+) of (\d+)/);
const deleted = match ? parseInt(match[1], 10) : 0;
toast.error(t("empty_folder.title"), t("empty_folder.error", { deleted, total: totalCount, folder: folderName }));
}
}, [emptyFolderTarget, client, emptyFolder, t]);
const mailboxTree = buildMailboxTree(mailboxes);
useEffect(() => {
@@ -407,9 +571,10 @@ export function Sidebar({
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
"bg-secondary border-border",
"max-lg:w-full",
isCollapsed ? "lg:w-16" : "lg:w-64",
isCollapsed && "lg:w-16",
className
)}
style={!isCollapsed ? { width: `var(--sidebar-width, ${sidebarWidth}px)` } : undefined}
>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
@@ -493,14 +658,56 @@ export function Sidebar({
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
onMailboxContextMenu={handleMailboxContextMenu}
isCollapsed={isCollapsed}
/>
))}
</>
)}
</div>
{/* Tags Section */}
<TagsSection isCollapsed={isCollapsed} onSearch={onSearch} />
</div>
{/* Mailbox Context Menu */}
{contextMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setContextMenu(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
/>
<div
className="fixed z-50 bg-popover border border-border rounded-md shadow-lg py-1 min-w-[160px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
onClick={() => {
setEmptyFolderTarget(contextMenu.mailbox);
setContextMenu(null);
}}
className="flex items-center w-full px-3 py-2 text-sm text-destructive hover:bg-muted transition-colors"
>
<Trash2 className="w-4 h-4 mr-2" />
{t("empty_folder.title")}
</button>
</div>
</>
)}
{/* Empty Folder Confirmation Dialog */}
{emptyFolderTarget && (
<EmptyFolderConfirmDialog
mailbox={{
name: emptyFolderTarget.name,
totalEmails: emptyFolderTarget.totalEmails || 0,
}}
onConfirm={handleEmptyFolder}
onCancel={() => setEmptyFolderTarget(null)}
/>
)}
{/* Footer: Storage Quota + Sign Out + Push Status */}
<div className="border-t border-border">
<StorageQuota quota={quota ?? null} isCollapsed={isCollapsed} />
@@ -546,6 +753,23 @@ export function Sidebar({
)}
</div>
</div>
{/* Resize Handle */}
{!isCollapsed && (
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hidden lg:block hover:bg-primary/20 active:bg-primary/30 transition-colors z-10"
onMouseDown={resizeHandle.handleMouseDown}
onTouchStart={resizeHandle.handleTouchStart}
onKeyDown={resizeHandle.handleKeyDown}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
aria-valuemin={180}
aria-valuemax={400}
aria-valuenow={sidebarWidth}
tabIndex={0}
/>
)}
</div>
);
}
+2 -1
View File
@@ -49,9 +49,10 @@ export function AppearanceSettings() {
<RadioGroup
value={listDensity}
onChange={(value) =>
updateSetting('listDensity', value as 'compact' | 'regular' | 'comfortable')
updateSetting('listDensity', value as 'extra-compact' | 'compact' | 'regular' | 'comfortable')
}
options={[
{ value: 'extra-compact', label: t('list_density.extra_compact') },
{ value: 'compact', label: t('list_density.compact') },
{ value: 'regular', label: t('list_density.regular') },
{ value: 'comfortable', label: t('list_density.comfortable') },
+14
View File
@@ -116,10 +116,15 @@ export function ContextMenuSubMenu({
}: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [subMenuPosition, setSubMenuPosition] = useState<"right" | "left">("right");
const [isTouchDevice, setIsTouchDevice] = useState(false);
const itemRef = useRef<HTMLDivElement>(null);
const subMenuRef = useRef<HTMLDivElement>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setIsTouchDevice(window.matchMedia('(pointer: coarse)').matches);
}, []);
useEffect(() => {
if (isOpen && itemRef.current) {
const rect = itemRef.current.getBoundingClientRect();
@@ -138,6 +143,7 @@ export function ContextMenuSubMenu({
}, []);
const handleMouseEnter = () => {
if (isTouchDevice) return;
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
@@ -146,11 +152,18 @@ export function ContextMenuSubMenu({
};
const handleMouseLeave = () => {
if (isTouchDevice) return;
closeTimerRef.current = setTimeout(() => {
setIsOpen(false);
}, 150);
};
const handleClick = () => {
if (isTouchDevice) {
setIsOpen(prev => !prev);
}
};
return (
<div
ref={itemRef}
@@ -167,6 +180,7 @@ export function ContextMenuSubMenu({
role="menuitem"
aria-haspopup="true"
aria-expanded={isOpen}
onClick={handleClick}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
+67
View File
@@ -0,0 +1,67 @@
import { useRef, useCallback } from 'react';
interface UseLongPressOptions {
delay?: number;
onLongPress: (e: React.TouchEvent) => void;
moveThreshold?: number;
}
export function useLongPress({ delay = 300, onLongPress, moveThreshold = 10 }: UseLongPressOptions) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startPos = useRef<{ x: number; y: number } | null>(null);
const targetRef = useRef<HTMLElement | null>(null);
const triggered = useRef(false);
const clear = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (targetRef.current) {
targetRef.current.style.transform = '';
targetRef.current = null;
}
triggered.current = false;
}, []);
const onTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
startPos.current = { x: touch.clientX, y: touch.clientY };
targetRef.current = e.currentTarget as HTMLElement;
triggered.current = false;
timerRef.current = setTimeout(() => {
triggered.current = true;
if (targetRef.current) {
targetRef.current.style.transform = '';
}
if (navigator.vibrate) {
navigator.vibrate(10);
}
onLongPress(e);
}, delay);
if (targetRef.current) {
targetRef.current.style.transform = 'scale(0.98)';
}
}, [delay, onLongPress]);
const onTouchMove = useCallback((e: React.TouchEvent) => {
if (!startPos.current) return;
const touch = e.touches[0];
const dx = Math.abs(touch.clientX - startPos.current.x);
const dy = Math.abs(touch.clientY - startPos.current.y);
if (dx > moveThreshold || dy > moveThreshold) {
clear();
}
}, [moveThreshold, clear]);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
if (triggered.current) {
e.preventDefault();
}
clear();
}, [clear]);
return { onTouchStart, onTouchMove, onTouchEnd };
}
+108
View File
@@ -0,0 +1,108 @@
import { useRef, useCallback, useEffect } from 'react';
interface UseResizeHandleOptions {
min: number;
max: number;
initial: number;
onResize: (width: number) => void;
onResizeEnd: (width: number) => void;
}
export function useResizeHandle({ min, max, initial, onResize, onResizeEnd }: UseResizeHandleOptions) {
const isDragging = useRef(false);
const startX = useRef(0);
const startWidth = useRef(initial);
const currentWidth = useRef(initial);
const clamp = useCallback((value: number) => Math.min(max, Math.max(min, value)), [min, max]);
useEffect(() => {
currentWidth.current = initial;
}, [initial]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
e.preventDefault();
const delta = e.clientX - startX.current;
const newWidth = clamp(startWidth.current + delta);
currentWidth.current = newWidth;
onResize(newWidth);
};
const handleMouseUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
onResizeEnd(currentWidth.current);
};
const handleTouchMove = (e: TouchEvent) => {
if (!isDragging.current) return;
const touch = e.touches[0];
const delta = touch.clientX - startX.current;
const newWidth = clamp(startWidth.current + delta);
currentWidth.current = newWidth;
onResize(newWidth);
};
const handleTouchEnd = () => {
if (!isDragging.current) return;
isDragging.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
onResizeEnd(currentWidth.current);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: true });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, [clamp, onResize, onResizeEnd]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
startX.current = e.clientX;
startWidth.current = currentWidth.current;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, []);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
isDragging.current = true;
startX.current = touch.clientX;
startWidth.current = currentWidth.current;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const step = 20;
let newWidth: number | null = null;
if (e.key === 'ArrowRight') {
newWidth = clamp(currentWidth.current + step);
} else if (e.key === 'ArrowLeft') {
newWidth = clamp(currentWidth.current - step);
}
if (newWidth !== null) {
e.preventDefault();
currentWidth.current = newWidth;
onResize(newWidth);
onResizeEnd(newWidth);
}
}, [clamp, onResize, onResizeEnd]);
return { handleMouseDown, handleTouchStart, handleKeyDown };
}
+51
View File
@@ -4,6 +4,7 @@ import {
sanitizeSignatureHtml,
parseHtmlSafely,
hasRichFormatting,
needsIframeRendering,
} from '../email-sanitization';
describe('email-sanitization', () => {
@@ -192,4 +193,54 @@ describe('email-sanitization', () => {
expect(hasRichFormatting(' ')).toBe(false);
});
});
describe('needsIframeRendering', () => {
it('returns true for HTML with table tags', () => {
expect(needsIframeRendering('<table><tr><td>Cell</td></tr></table>')).toBe(true);
});
it('returns true for HTML with style tags', () => {
expect(needsIframeRendering('<style>body { color: red; }</style><p>Text</p>')).toBe(true);
});
it('returns true for HTML with background in inline styles', () => {
expect(needsIframeRendering('<div style="background: url(img.png)">Content</div>')).toBe(true);
});
it('returns true for HTML with background-image in inline styles', () => {
expect(needsIframeRendering('<div style="background-image: url(bg.jpg)">Cell</div>')).toBe(true);
});
it('returns true for HTML with link tags', () => {
expect(needsIframeRendering('<link rel="stylesheet" href="style.css"><p>Text</p>')).toBe(true);
});
it('returns false for plain text converted to HTML', () => {
expect(needsIframeRendering('<br>Hello world<br>How are you?')).toBe(false);
});
it('returns false for simple formatting tags', () => {
expect(needsIframeRendering('<p><b>Bold</b> and <i>italic</i> text</p>')).toBe(false);
});
it('returns false for blockquotes', () => {
expect(needsIframeRendering('<blockquote>Quoted text</blockquote>')).toBe(false);
});
it('returns false for lists', () => {
expect(needsIframeRendering('<ul><li>Item 1</li><li>Item 2</li></ul>')).toBe(false);
});
it('returns false for headings', () => {
expect(needsIframeRendering('<h1>Title</h1><p>Body</p>')).toBe(false);
});
it('returns false for simple inline styles without background', () => {
expect(needsIframeRendering('<p style="color: red; font-size: 14px">Text</p>')).toBe(false);
});
it('returns false for empty string', () => {
expect(needsIframeRendering('')).toBe(false);
});
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { retryWithBackoff } from '../jmap/retry';
describe('retryWithBackoff', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns immediately on success', async () => {
const fn = vi.fn().mockResolvedValue(new Response('ok', { status: 200 }));
const result = await retryWithBackoff(fn);
expect(fn).toHaveBeenCalledTimes(1);
expect(result.status).toBe(200);
});
it('retries on 503 and succeeds', async () => {
const fn = vi.fn()
.mockResolvedValueOnce(new Response('', { status: 503 }))
.mockResolvedValue(new Response('ok', { status: 200 }));
const promise = retryWithBackoff(fn);
await vi.advanceTimersByTimeAsync(700);
const result = await promise;
expect(fn).toHaveBeenCalledTimes(2);
expect(result.status).toBe(200);
});
it('retries on 429 and succeeds', async () => {
const fn = vi.fn()
.mockResolvedValueOnce(new Response('', { status: 429 }))
.mockResolvedValue(new Response('ok', { status: 200 }));
const promise = retryWithBackoff(fn);
await vi.advanceTimersByTimeAsync(700);
const result = await promise;
expect(fn).toHaveBeenCalledTimes(2);
expect(result.status).toBe(200);
});
it('retries on TypeError (network failure)', async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValue(new Response('ok', { status: 200 }));
const promise = retryWithBackoff(fn);
await vi.advanceTimersByTimeAsync(700);
const result = await promise;
expect(fn).toHaveBeenCalledTimes(2);
expect(result.status).toBe(200);
});
it('does NOT retry on 400', async () => {
const fn = vi.fn().mockResolvedValue(new Response('bad', { status: 400 }));
const result = await retryWithBackoff(fn);
expect(fn).toHaveBeenCalledTimes(1);
expect(result.status).toBe(400);
});
it('does NOT retry on 401', async () => {
const fn = vi.fn().mockResolvedValue(new Response('unauthorized', { status: 401 }));
const result = await retryWithBackoff(fn);
expect(fn).toHaveBeenCalledTimes(1);
expect(result.status).toBe(401);
});
it('gives up after max retries', async () => {
const fn = vi.fn().mockResolvedValue(new Response('', { status: 503 }));
const promise = retryWithBackoff(fn, { maxRetries: 3 });
for (let i = 0; i < 3; i++) {
await vi.advanceTimersByTimeAsync(3000);
}
const result = await promise;
expect(fn).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
expect(result.status).toBe(503);
});
it('respects Retry-After header on 429', async () => {
const headers = new Headers({ 'Retry-After': '2' });
const fn = vi.fn()
.mockResolvedValueOnce(new Response('', { status: 429, headers }))
.mockResolvedValue(new Response('ok', { status: 200 }));
const promise = retryWithBackoff(fn);
await vi.advanceTimersByTimeAsync(2500);
const result = await promise;
expect(fn).toHaveBeenCalledTimes(2);
expect(result.status).toBe(200);
});
it('respects AbortSignal', async () => {
const controller = new AbortController();
const fn = vi.fn().mockResolvedValue(new Response('', { status: 503 }));
controller.abort();
await expect(
retryWithBackoff(fn, { signal: controller.signal })
).rejects.toThrow();
});
});
+37
View File
@@ -215,3 +215,40 @@ export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'):
return transformedProps.join('; ');
}
/**
* Generate a style block for iframe-rendered emails.
* Uses prefers-color-scheme for native dark mode adaptation
* instead of inline style transforms.
*/
export function generateIframeStylesheet(): string {
return `
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: break-word;
overflow-x: auto;
color: #1e293b;
background: #ffffff;
}
img { max-width: 100%; height: auto; }
table { max-width: 100%; }
a { color: #3b82f6; }
pre, code { white-space: pre-wrap; word-wrap: break-word; }
@media (prefers-color-scheme: dark) {
body {
color: #e2e8f0;
background: #1e1f26;
}
a { color: #60a5fa; }
img { opacity: 0.9; }
}
</style>
`;
}
+21
View File
@@ -76,6 +76,27 @@ export function hasRichFormatting(html: string): boolean {
);
}
/**
* Detect if HTML content needs iframe rendering for CSS isolation.
* More narrow than hasRichFormatting() — only triggers on patterns
* that can cause CSS bleed into the host app:
* - table layouts (newsletter-style)
* - style blocks (global CSS rules)
* - link tags (external stylesheets)
* - background/background-image in inline styles (complex rendering)
*/
export function needsIframeRendering(html: string): boolean {
if (!html) return false;
const doc = parseHtmlSafely(html);
if (doc.querySelector('table, style, link[rel="stylesheet"]')) return true;
const allElements = doc.querySelectorAll('[style]');
for (const el of allElements) {
const styleAttr = el.getAttribute('style') || '';
if (/background(?:-image)?\s*:.*url\s*\(/i.test(styleAttr)) return true;
}
return false;
}
/**
* Collapse empty containers left behind when external images are blocked.
* Walks up from each blocked img to find the nearest table cell or wrapper div
+65 -6
View File
@@ -1,5 +1,6 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import { retryWithBackoff } from './retry';
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
@@ -141,9 +142,22 @@ export class JMAPClient {
this.authHeader = `Bearer ${token}`;
}
private async authenticatedFetch(url: string, init?: Parameters<typeof fetch>[1]): Promise<Response> {
private async authenticatedFetch(
url: string,
init?: Parameters<typeof fetch>[1],
options?: { retry?: boolean }
): Promise<Response> {
const headers = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
let response = await fetch(url, { ...init, headers });
const doFetch = () => fetch(url, { ...init, headers });
let response: Response;
if (options?.retry !== false) {
response = await retryWithBackoff(doFetch, {
signal: init?.signal as AbortSignal | undefined,
});
} else {
response = await doFetch();
}
if (response.status === 401 && this.authMode === 'bearer' && this.onTokenRefresh) {
const newToken = await this.onTokenRefresh();
@@ -672,6 +686,51 @@ export class JMAPClient {
]);
}
async queryTagCounts(tags: string[]): Promise<Record<string, number>> {
if (tags.length === 0) return {};
const methodCalls: JMAPMethodCall[] = tags.map((tag, i) => [
"Email/query",
{
accountId: this.accountId,
filter: { hasKeyword: `$color:${tag}` },
calculateTotal: true,
limit: 0,
},
`tag-${i}`,
]);
const response = await this.request(methodCalls);
const counts: Record<string, number> = {};
response.methodResponses?.forEach(([method, result], i) => {
if (method === "Email/query" && result?.total > 0) {
counts[tags[i]] = result.total;
}
});
return counts;
}
async queryMailboxEmailIds(mailboxId: string, limit: number = 500, position: number = 0): Promise<{ ids: string[]; total: number }> {
const response = await this.request([
["Email/query", {
accountId: this.accountId,
filter: { inMailbox: mailboxId },
sort: [{ property: "receivedAt", isAscending: false }],
calculateTotal: true,
limit,
position,
}, "0"],
]);
const queryResult = response.methodResponses?.[0]?.[1];
return {
ids: queryResult?.ids || [],
total: queryResult?.total || 0,
};
}
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
if (emailIds.length === 0) return;
@@ -1258,7 +1317,7 @@ export class JMAPClient {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
}, { retry: false });
if (!response.ok) {
const errorText = await response.text();
@@ -2087,7 +2146,7 @@ export class JMAPClient {
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await this.authenticatedFetch(url, {});
const response = await this.authenticatedFetch(url, {}, { retry: false });
if (!response.ok) {
throw new Error(`Failed to download attachment: ${response.status}`);
@@ -2158,7 +2217,7 @@ export class JMAPClient {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls }),
});
}, { retry: false });
if (response.ok) {
const data = await response.json();
@@ -2181,7 +2240,7 @@ export class JMAPClient {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ using, methodCalls }),
});
}, { retry: false });
if (response.ok) {
const data = await response.json();
+50
View File
@@ -0,0 +1,50 @@
const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
interface RetryOptions {
maxRetries?: number;
baseDelay?: number;
signal?: AbortSignal;
}
export async function retryWithBackoff(
fn: () => Promise<Response>,
options: RetryOptions = {}
): Promise<Response> {
const { maxRetries = 3, baseDelay = 500, signal } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
try {
const response = await fn();
if (attempt < maxRetries && RETRYABLE_STATUS_CODES.has(response.status)) {
let delay: number;
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const retrySeconds = retryAfter ? parseInt(retryAfter, 10) : NaN;
delay = !isNaN(retrySeconds) ? retrySeconds * 1000 : baseDelay * Math.pow(2, attempt);
} else {
delay = baseDelay * Math.pow(2, attempt);
}
const jitter = delay * (0.8 + Math.random() * 0.4);
await new Promise(resolve => setTimeout(resolve, jitter));
continue;
}
return response;
} catch (error) {
if (error instanceof TypeError && attempt < maxRetries) {
const delay = baseDelay * Math.pow(2, attempt);
const jitter = delay * (0.8 + Math.random() * 0.4);
await new Promise(resolve => setTimeout(resolve, jitter));
continue;
}
throw error;
}
}
throw new Error('Retry loop exited unexpectedly');
}
+52 -2
View File
@@ -88,7 +88,19 @@
"compose_hint": "Verfassen (c)",
"search_placeholder_hint": "E-Mails suchen... (drücke /)",
"mail": "E-Mail",
"nav_label": "Navigation"
"nav_label": "Navigation",
"tags": {
"title": "Tags",
"no_tags": "Keine Tags"
},
"empty_folder": {
"title": "Ordner leeren",
"confirm": "Möchten Sie wirklich alle {count} E-Mails in {folder} dauerhaft löschen?",
"success": "Ordner erfolgreich geleert",
"progress": "Lösche... {deleted}/{total}",
"error": "Es konnten nicht alle E-Mails gelöscht werden. {deleted} von {total} wurden entfernt.",
"cancel": "Abbrechen"
}
},
"email_list": {
"no_emails": "Keine E-Mails",
@@ -161,6 +173,26 @@
"more_options": "Weitere Optionen",
"sending": "Wird gesendet...",
"security_authentication": "Sicherheit & Authentifizierung",
"security": {
"tooltip": {
"spf_pass": "Der sendende Server ist berechtigt, im Namen dieser Domain zu senden",
"spf_fail": "Der sendende Server ist NICHT berechtigt, von dieser Domain zu senden — diese E-Mail könnte gefälscht sein",
"spf_softfail": "Der sendende Server ist wahrscheinlich nicht berechtigt — mit Vorsicht behandeln",
"spf_neutral": "Der Domaininhaber hat nicht angegeben, ob dieser Server berechtigt ist",
"spf_temperror": "Bei der Überprüfung des Absenders ist ein vorübergehender Fehler aufgetreten",
"spf_permerror": "Der SPF-Eintrag der Domain ist fehlerhaft konfiguriert",
"spf_none": "Kein SPF-Eintrag für diese Domain gefunden",
"dkim_pass": "Der Inhalt dieser E-Mail wurde während der Übertragung nicht verändert",
"dkim_fail": "Der Inhalt dieser E-Mail wurde möglicherweise während der Übertragung verändert",
"dkim_policy": "Die DKIM-Signatur erfüllt nicht die Richtlinienanforderungen der Domain",
"dkim_neutral": "Die DKIM-Prüfung war nicht schlüssig",
"dkim_temperror": "Bei der Überprüfung der Signatur ist ein vorübergehender Fehler aufgetreten",
"dkim_permerror": "Die DKIM-Signatur ist fehlerhaft oder der Schlüssel fehlt",
"dmarc_pass": "Die Domain des Absenders hat die Echtheit dieser E-Mail bestätigt",
"dmarc_fail": "Die Domain des Absenders konnte die Echtheit dieser E-Mail nicht bestätigen",
"dmarc_none": "Die Domain des Absenders hat keine DMARC-Richtlinie konfiguriert"
}
},
"technical_details": "Technische Details",
"message_id_label": "Nachrichten-ID:",
"reply_to_label": "Antwort an:",
@@ -270,7 +302,24 @@
"select_calendar": "Kalender auswählen",
"already_in_calendar": "Bereits in deinem Kalender"
},
"keyboard_shortcuts": "Tastaturkürzel (?)"
"keyboard_shortcuts": "Tastaturkürzel (?)",
"mobile_actions": {
"reply": "Antworten",
"reply_all": "Allen antworten",
"archive": "Archivieren",
"delete": "Löschen",
"more": "Mehr",
"forward": "Weiterleiten",
"move_to": "Verschieben nach...",
"star": "Markieren",
"mark_unread": "Als ungelesen markieren",
"spam": "Als Spam melden"
},
"sender_info": {
"add_to_contacts": "Zu Kontakten hinzufügen",
"view_all_emails": "Alle E-Mails dieses Absenders anzeigen",
"no_contact": "Nicht in Ihren Kontakten"
}
},
"email_composer": {
"new_message": "Neue Nachricht",
@@ -469,6 +518,7 @@
"list_density": {
"label": "Listendichte",
"description": "Abstand in E-Mail-Listen steuern",
"extra_compact": "Extra kompakt",
"compact": "Kompakt",
"regular": "Normal",
"comfortable": "Komfortabel"
+51 -1
View File
@@ -88,7 +88,19 @@
"clear_search": "Clear search",
"vacation_active": "Vacation responder is active",
"mail": "Mail",
"nav_label": "Navigation"
"nav_label": "Navigation",
"tags": {
"title": "Tags",
"no_tags": "No tags"
},
"empty_folder": {
"title": "Empty folder",
"confirm": "Are you sure you want to permanently delete all {count} emails in {folder}?",
"success": "Folder emptied successfully",
"progress": "Deleting... {deleted}/{total}",
"error": "Failed to delete all emails. {deleted} of {total} were removed.",
"cancel": "Cancel"
}
},
"email_list": {
"no_emails": "No emails",
@@ -162,6 +174,26 @@
"more_options": "More options",
"sending": "Sending...",
"security_authentication": "Security & Authentication",
"security": {
"tooltip": {
"spf_pass": "The sending server is authorized to send on behalf of this domain",
"spf_fail": "The sending server is NOT authorized to send from this domain — this email may be spoofed",
"spf_softfail": "The sending server is probably not authorized — treat with caution",
"spf_neutral": "The domain owner has not stated whether this server is authorized",
"spf_temperror": "A temporary error occurred checking the sender — try again later",
"spf_permerror": "The domain's SPF record is misconfigured",
"spf_none": "No SPF record found for this domain",
"dkim_pass": "This email's content has not been tampered with in transit",
"dkim_fail": "This email's content may have been altered in transit",
"dkim_policy": "DKIM signature did not meet the domain's policy requirements",
"dkim_neutral": "DKIM check was inconclusive",
"dkim_temperror": "A temporary error occurred verifying the signature",
"dkim_permerror": "The DKIM signature is malformed or the key is missing",
"dmarc_pass": "The sender's domain has verified this email is authentic",
"dmarc_fail": "The sender's domain could not verify this email's authenticity",
"dmarc_none": "The sender's domain has no DMARC policy configured"
}
},
"technical_details": "Technical Details",
"message_id_label": "Message-ID:",
"reply_to_label": "Reply-To:",
@@ -270,6 +302,23 @@
"no_calendar": "Calendar not available",
"select_calendar": "Select calendar",
"already_in_calendar": "Already in your calendar"
},
"mobile_actions": {
"reply": "Reply",
"reply_all": "Reply All",
"archive": "Archive",
"delete": "Delete",
"more": "More",
"forward": "Forward",
"move_to": "Move to...",
"star": "Star",
"mark_unread": "Mark as unread",
"spam": "Report spam"
},
"sender_info": {
"add_to_contacts": "Add to contacts",
"view_all_emails": "View all emails from this sender",
"no_contact": "Not in your contacts"
}
},
"email_composer": {
@@ -477,6 +526,7 @@
"list_density": {
"label": "List Density",
"description": "Control spacing in email lists",
"extra_compact": "Extra compact",
"compact": "Compact",
"regular": "Regular",
"comfortable": "Comfortable"
+52 -2
View File
@@ -88,7 +88,19 @@
"compose_hint": "Redactar (c)",
"search_placeholder_hint": "Buscar correo... (pulsa /)",
"mail": "Correo",
"nav_label": "Navegación"
"nav_label": "Navegación",
"tags": {
"title": "Etiquetas",
"no_tags": "Sin etiquetas"
},
"empty_folder": {
"title": "Vaciar carpeta",
"confirm": "¿Estás seguro de que deseas eliminar permanentemente los {count} correos en {folder}?",
"success": "Carpeta vaciada correctamente",
"progress": "Eliminando... {deleted}/{total}",
"error": "No se pudieron eliminar todos los correos. Se eliminaron {deleted} de {total}.",
"cancel": "Cancelar"
}
},
"email_list": {
"no_emails": "Sin correos",
@@ -161,6 +173,26 @@
"more_options": "Más opciones",
"sending": "Enviando...",
"security_authentication": "Seguridad y Autenticación",
"security": {
"tooltip": {
"spf_pass": "El servidor de envío está autorizado para enviar en nombre de este dominio",
"spf_fail": "El servidor de envío NO está autorizado para enviar desde este dominio — este correo puede ser falsificado",
"spf_softfail": "El servidor de envío probablemente no está autorizado — tratar con precaución",
"spf_neutral": "El propietario del dominio no ha declarado si este servidor está autorizado",
"spf_temperror": "Se produjo un error temporal al verificar el remitente",
"spf_permerror": "El registro SPF del dominio está mal configurado",
"spf_none": "No se encontró registro SPF para este dominio",
"dkim_pass": "El contenido de este correo no ha sido alterado en tránsito",
"dkim_fail": "El contenido de este correo puede haber sido alterado en tránsito",
"dkim_policy": "La firma DKIM no cumplió con los requisitos de la política del dominio",
"dkim_neutral": "La verificación DKIM no fue concluyente",
"dkim_temperror": "Se produjo un error temporal al verificar la firma",
"dkim_permerror": "La firma DKIM está malformada o falta la clave",
"dmarc_pass": "El dominio del remitente ha verificado que este correo es auténtico",
"dmarc_fail": "El dominio del remitente no pudo verificar la autenticidad de este correo",
"dmarc_none": "El dominio del remitente no tiene política DMARC configurada"
}
},
"technical_details": "Detalles Técnicos",
"message_id_label": "ID del Mensaje:",
"reply_to_label": "Responder a:",
@@ -270,7 +302,24 @@
"select_calendar": "Seleccionar calendario",
"already_in_calendar": "Ya está en tu calendario"
},
"keyboard_shortcuts": "Atajos de teclado (?)"
"keyboard_shortcuts": "Atajos de teclado (?)",
"mobile_actions": {
"reply": "Responder",
"reply_all": "Responder a todos",
"archive": "Archivar",
"delete": "Eliminar",
"more": "Más",
"forward": "Reenviar",
"move_to": "Mover a...",
"star": "Destacar",
"mark_unread": "Marcar como no leído",
"spam": "Reportar spam"
},
"sender_info": {
"add_to_contacts": "Añadir a contactos",
"view_all_emails": "Ver todos los correos de este remitente",
"no_contact": "No está en tus contactos"
}
},
"email_composer": {
"new_message": "Nuevo Mensaje",
@@ -469,6 +518,7 @@
"list_density": {
"label": "Densidad de Lista",
"description": "Controle el espaciado en las listas de correo",
"extra_compact": "Extra compacto",
"compact": "Compacto",
"regular": "Regular",
"comfortable": "Cómodo"
+52 -2
View File
@@ -88,7 +88,19 @@
"clear_search": "Effacer la recherche",
"vacation_active": "Répondeur d'absence activé",
"mail": "Messagerie",
"nav_label": "Navigation"
"nav_label": "Navigation",
"tags": {
"title": "Étiquettes",
"no_tags": "Aucune étiquette"
},
"empty_folder": {
"title": "Vider le dossier",
"confirm": "Êtes-vous sûr de vouloir supprimer définitivement les {count} emails dans {folder} ?",
"success": "Dossier vidé avec succès",
"progress": "Suppression... {deleted}/{total}",
"error": "Échec de la suppression de tous les emails. {deleted} sur {total} ont été supprimés.",
"cancel": "Annuler"
}
},
"email_list": {
"no_emails": "Aucun email",
@@ -161,6 +173,26 @@
"more_options": "Plus d'options",
"sending": "Envoi en cours...",
"security_authentication": "Sécurité et authentification",
"security": {
"tooltip": {
"spf_pass": "Le serveur d'envoi est autorisé à envoyer pour le compte de ce domaine",
"spf_fail": "Le serveur d'envoi n'est PAS autorisé à envoyer depuis ce domaine — cet email peut être usurpé",
"spf_softfail": "Le serveur d'envoi n'est probablement pas autorisé — à traiter avec prudence",
"spf_neutral": "Le propriétaire du domaine n'a pas indiqué si ce serveur est autorisé",
"spf_temperror": "Une erreur temporaire s'est produite lors de la vérification de l'expéditeur",
"spf_permerror": "L'enregistrement SPF du domaine est mal configuré",
"spf_none": "Aucun enregistrement SPF trouvé pour ce domaine",
"dkim_pass": "Le contenu de cet email n'a pas été altéré durant le transit",
"dkim_fail": "Le contenu de cet email a peut-être été modifié durant le transit",
"dkim_policy": "La signature DKIM ne respecte pas les exigences de la politique du domaine",
"dkim_neutral": "La vérification DKIM n'est pas concluante",
"dkim_temperror": "Une erreur temporaire s'est produite lors de la vérification de la signature",
"dkim_permerror": "La signature DKIM est malformée ou la clé est manquante",
"dmarc_pass": "Le domaine de l'expéditeur a vérifié l'authenticité de cet email",
"dmarc_fail": "Le domaine de l'expéditeur n'a pas pu vérifier l'authenticité de cet email",
"dmarc_none": "Le domaine de l'expéditeur n'a pas de politique DMARC configurée"
}
},
"technical_details": "Détails techniques",
"message_id_label": "ID du message :",
"reply_to_label": "Répondre à :",
@@ -270,7 +302,24 @@
"select_calendar": "Choisir un calendrier",
"already_in_calendar": "Déjà dans votre calendrier"
},
"keyboard_shortcuts": "Raccourcis clavier (?)"
"keyboard_shortcuts": "Raccourcis clavier (?)",
"mobile_actions": {
"reply": "Répondre",
"reply_all": "Répondre à tous",
"archive": "Archiver",
"delete": "Supprimer",
"more": "Plus",
"forward": "Transférer",
"move_to": "Déplacer vers...",
"star": "Marquer",
"mark_unread": "Marquer comme non lu",
"spam": "Signaler comme spam"
},
"sender_info": {
"add_to_contacts": "Ajouter aux contacts",
"view_all_emails": "Voir tous les e-mails de cet expéditeur",
"no_contact": "Pas dans vos contacts"
}
},
"email_composer": {
"new_message": "Nouveau message",
@@ -469,6 +518,7 @@
"list_density": {
"label": "Densité de la liste",
"description": "Contrôlez l'espacement dans les listes d'emails",
"extra_compact": "Très compacte",
"compact": "Compacte",
"regular": "Normale",
"comfortable": "Confortable"
+52 -2
View File
@@ -88,7 +88,19 @@
"compose_hint": "Scrivi (c)",
"search_placeholder_hint": "Cerca posta... (premi /)",
"mail": "Posta",
"nav_label": "Navigazione"
"nav_label": "Navigazione",
"tags": {
"title": "Etichette",
"no_tags": "Nessuna etichetta"
},
"empty_folder": {
"title": "Svuota cartella",
"confirm": "Sei sicuro di voler eliminare definitivamente tutte le {count} email in {folder}?",
"success": "Cartella svuotata con successo",
"progress": "Eliminazione... {deleted}/{total}",
"error": "Impossibile eliminare tutte le email. {deleted} su {total} sono state rimosse.",
"cancel": "Annulla"
}
},
"email_list": {
"no_emails": "Nessun messaggio",
@@ -161,6 +173,26 @@
"more_options": "Più opzioni",
"sending": "Invio in corso...",
"security_authentication": "Sicurezza e autenticazione",
"security": {
"tooltip": {
"spf_pass": "Il server di invio è autorizzato a inviare per conto di questo dominio",
"spf_fail": "Il server di invio NON è autorizzato a inviare da questo dominio — questa email potrebbe essere contraffatta",
"spf_softfail": "Il server di invio probabilmente non è autorizzato — trattare con cautela",
"spf_neutral": "Il proprietario del dominio non ha dichiarato se questo server è autorizzato",
"spf_temperror": "Si è verificato un errore temporaneo durante la verifica del mittente",
"spf_permerror": "Il record SPF del dominio è configurato in modo errato",
"spf_none": "Nessun record SPF trovato per questo dominio",
"dkim_pass": "Il contenuto di questa email non è stato manomesso durante il transito",
"dkim_fail": "Il contenuto di questa email potrebbe essere stato alterato durante il transito",
"dkim_policy": "La firma DKIM non soddisfa i requisiti della politica del dominio",
"dkim_neutral": "La verifica DKIM non è stata conclusiva",
"dkim_temperror": "Si è verificato un errore temporaneo durante la verifica della firma",
"dkim_permerror": "La firma DKIM è malformata o la chiave è mancante",
"dmarc_pass": "Il dominio del mittente ha verificato che questa email è autentica",
"dmarc_fail": "Il dominio del mittente non ha potuto verificare l'autenticità di questa email",
"dmarc_none": "Il dominio del mittente non ha una politica DMARC configurata"
}
},
"technical_details": "Dettagli tecnici",
"message_id_label": "ID messaggio:",
"reply_to_label": "Rispondi a:",
@@ -270,7 +302,24 @@
"select_calendar": "Seleziona calendario",
"already_in_calendar": "Già nel tuo calendario"
},
"keyboard_shortcuts": "Scorciatoie da tastiera (?)"
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
"mobile_actions": {
"reply": "Rispondi",
"reply_all": "Rispondi a tutti",
"archive": "Archivia",
"delete": "Elimina",
"more": "Altro",
"forward": "Inoltra",
"move_to": "Sposta in...",
"star": "Preferito",
"mark_unread": "Segna come non letto",
"spam": "Segnala come spam"
},
"sender_info": {
"add_to_contacts": "Aggiungi ai contatti",
"view_all_emails": "Visualizza tutte le email di questo mittente",
"no_contact": "Non presente nei tuoi contatti"
}
},
"email_composer": {
"new_message": "Nuovo messaggio",
@@ -469,6 +518,7 @@
"list_density": {
"label": "Densità elenco",
"description": "Controlla la spaziatura negli elenchi email",
"extra_compact": "Extra compatto",
"compact": "Compatto",
"regular": "Normale",
"comfortable": "Comodo"
+52 -2
View File
@@ -88,7 +88,19 @@
"clear_search": "検索をクリア",
"vacation_active": "不在応答が有効です",
"mail": "メール",
"nav_label": "ナビゲーション"
"nav_label": "ナビゲーション",
"tags": {
"title": "タグ",
"no_tags": "タグなし"
},
"empty_folder": {
"title": "フォルダを空にする",
"confirm": "{folder}内の{count}件のメールをすべて完全に削除しますか?",
"success": "フォルダを空にしました",
"progress": "削除中... {deleted}/{total}",
"error": "すべてのメールを削除できませんでした。{total}件中{deleted}件が削除されました。",
"cancel": "キャンセル"
}
},
"email_list": {
"no_emails": "メールがありません",
@@ -161,6 +173,26 @@
"more_options": "その他のオプション",
"sending": "送信中...",
"security_authentication": "セキュリティと認証",
"security": {
"tooltip": {
"spf_pass": "送信サーバーはこのドメインの代理として送信する権限があります",
"spf_fail": "送信サーバーはこのドメインからの送信を許可されていません — なりすましの可能性があります",
"spf_softfail": "送信サーバーはおそらく許可されていません — 注意が必要です",
"spf_neutral": "ドメイン所有者はこのサーバーが許可されているかどうかを明示していません",
"spf_temperror": "送信者の確認中に一時的なエラーが発生しました",
"spf_permerror": "ドメインのSPFレコードの設定に誤りがあります",
"spf_none": "このドメインのSPFレコードが見つかりません",
"dkim_pass": "このメールの内容は送信中に改ざんされていません",
"dkim_fail": "このメールの内容は送信中に改ざんされた可能性があります",
"dkim_policy": "DKIM署名がドメインのポリシー要件を満たしていません",
"dkim_neutral": "DKIM検証は結論が出ませんでした",
"dkim_temperror": "署名の検証中に一時的なエラーが発生しました",
"dkim_permerror": "DKIM署名が不正か、鍵が見つかりません",
"dmarc_pass": "送信者のドメインがこのメールの真正性を確認しました",
"dmarc_fail": "送信者のドメインがこのメールの真正性を確認できませんでした",
"dmarc_none": "送信者のドメインにDMARCポリシーが設定されていません"
}
},
"technical_details": "技術的な詳細",
"message_id_label": "メッセージID:",
"reply_to_label": "返信先:",
@@ -270,7 +302,24 @@
"select_calendar": "カレンダーを選択",
"already_in_calendar": "カレンダーに登録済み"
},
"keyboard_shortcuts": "キーボードショートカット (?)"
"keyboard_shortcuts": "キーボードショートカット (?)",
"mobile_actions": {
"reply": "返信",
"reply_all": "全員に返信",
"archive": "アーカイブ",
"delete": "削除",
"more": "その他",
"forward": "転送",
"move_to": "移動先...",
"star": "スター",
"mark_unread": "未読にする",
"spam": "迷惑メールとして報告"
},
"sender_info": {
"add_to_contacts": "連絡先に追加",
"view_all_emails": "この送信者のすべてのメールを表示",
"no_contact": "連絡先に登録されていません"
}
},
"email_composer": {
"new_message": "新規メッセージ",
@@ -469,6 +518,7 @@
"list_density": {
"label": "リストの密度",
"description": "メールリストの間隔を調整",
"extra_compact": "超コンパクト",
"compact": "コンパクト",
"regular": "標準",
"comfortable": "ゆったり"
+52 -2
View File
@@ -88,7 +88,19 @@
"compose_hint": "Opstellen (c)",
"search_placeholder_hint": "E-mail zoeken... (druk /)",
"mail": "E-mail",
"nav_label": "Navigatie"
"nav_label": "Navigatie",
"tags": {
"title": "Tags",
"no_tags": "Geen tags"
},
"empty_folder": {
"title": "Map leegmaken",
"confirm": "Weet u zeker dat u alle {count} e-mails in {folder} permanent wilt verwijderen?",
"success": "Map succesvol geleegd",
"progress": "Verwijderen... {deleted}/{total}",
"error": "Niet alle e-mails konden worden verwijderd. {deleted} van {total} zijn verwijderd.",
"cancel": "Annuleren"
}
},
"email_list": {
"no_emails": "Geen e-mails",
@@ -161,6 +173,26 @@
"more_options": "Meer opties",
"sending": "Verzenden...",
"security_authentication": "Beveiliging & Authenticatie",
"security": {
"tooltip": {
"spf_pass": "De verzendende server is geautoriseerd om namens dit domein te verzenden",
"spf_fail": "De verzendende server is NIET geautoriseerd om vanaf dit domein te verzenden — deze e-mail kan vervalst zijn",
"spf_softfail": "De verzendende server is waarschijnlijk niet geautoriseerd — wees voorzichtig",
"spf_neutral": "De domeineigenaar heeft niet aangegeven of deze server geautoriseerd is",
"spf_temperror": "Er is een tijdelijke fout opgetreden bij het controleren van de afzender",
"spf_permerror": "Het SPF-record van het domein is onjuist geconfigureerd",
"spf_none": "Geen SPF-record gevonden voor dit domein",
"dkim_pass": "De inhoud van deze e-mail is niet gewijzigd tijdens het transport",
"dkim_fail": "De inhoud van deze e-mail is mogelijk gewijzigd tijdens het transport",
"dkim_policy": "De DKIM-handtekening voldeed niet aan de beleidsvereisten van het domein",
"dkim_neutral": "De DKIM-controle was niet overtuigend",
"dkim_temperror": "Er is een tijdelijke fout opgetreden bij het verifiëren van de handtekening",
"dkim_permerror": "De DKIM-handtekening is ongeldig of de sleutel ontbreekt",
"dmarc_pass": "Het domein van de afzender heeft geverifieerd dat deze e-mail authentiek is",
"dmarc_fail": "Het domein van de afzender kon de authenticiteit van deze e-mail niet verifiëren",
"dmarc_none": "Het domein van de afzender heeft geen DMARC-beleid geconfigureerd"
}
},
"technical_details": "Technische details",
"message_id_label": "Bericht-ID:",
"reply_to_label": "Antwoord naar:",
@@ -270,7 +302,24 @@
"select_calendar": "Agenda selecteren",
"already_in_calendar": "Staat al in je agenda"
},
"keyboard_shortcuts": "Sneltoetsen (?)"
"keyboard_shortcuts": "Sneltoetsen (?)",
"mobile_actions": {
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden",
"archive": "Archiveren",
"delete": "Verwijderen",
"more": "Meer",
"forward": "Doorsturen",
"move_to": "Verplaatsen naar...",
"star": "Ster",
"mark_unread": "Markeren als ongelezen",
"spam": "Melden als spam"
},
"sender_info": {
"add_to_contacts": "Toevoegen aan contacten",
"view_all_emails": "Alle e-mails van deze afzender bekijken",
"no_contact": "Niet in uw contacten"
}
},
"email_composer": {
"new_message": "Nieuw bericht",
@@ -469,6 +518,7 @@
"list_density": {
"label": "Lijstdichtheid",
"description": "Regeleer de ruimte in e-maillijsten",
"extra_compact": "Extra compact",
"compact": "Compact",
"regular": "Normaal",
"comfortable": "Comfortabel"
+52 -2
View File
@@ -88,7 +88,19 @@
"compose_hint": "Compor (c)",
"search_placeholder_hint": "Pesquisar e-mail... (pressione /)",
"mail": "E-mail",
"nav_label": "Navegação"
"nav_label": "Navegação",
"tags": {
"title": "Etiquetas",
"no_tags": "Sem etiquetas"
},
"empty_folder": {
"title": "Esvaziar pasta",
"confirm": "Tem certeza de que deseja excluir permanentemente todos os {count} e-mails em {folder}?",
"success": "Pasta esvaziada com sucesso",
"progress": "Excluindo... {deleted}/{total}",
"error": "Não foi possível excluir todos os e-mails. {deleted} de {total} foram removidos.",
"cancel": "Cancelar"
}
},
"email_list": {
"no_emails": "Nenhum e-mail",
@@ -161,6 +173,26 @@
"more_options": "Mais opções",
"sending": "Enviando...",
"security_authentication": "Segurança & Autenticação",
"security": {
"tooltip": {
"spf_pass": "O servidor de envio está autorizado a enviar em nome deste domínio",
"spf_fail": "O servidor de envio NÃO está autorizado a enviar deste domínio — este e-mail pode ser falsificado",
"spf_softfail": "O servidor de envio provavelmente não está autorizado — trate com cuidado",
"spf_neutral": "O proprietário do domínio não declarou se este servidor está autorizado",
"spf_temperror": "Ocorreu um erro temporário ao verificar o remetente",
"spf_permerror": "O registro SPF do domínio está mal configurado",
"spf_none": "Nenhum registro SPF encontrado para este domínio",
"dkim_pass": "O conteúdo deste e-mail não foi adulterado durante o trânsito",
"dkim_fail": "O conteúdo deste e-mail pode ter sido alterado durante o trânsito",
"dkim_policy": "A assinatura DKIM não atendeu aos requisitos da política do domínio",
"dkim_neutral": "A verificação DKIM foi inconclusiva",
"dkim_temperror": "Ocorreu um erro temporário ao verificar a assinatura",
"dkim_permerror": "A assinatura DKIM está malformada ou a chave está ausente",
"dmarc_pass": "O domínio do remetente verificou que este e-mail é autêntico",
"dmarc_fail": "O domínio do remetente não conseguiu verificar a autenticidade deste e-mail",
"dmarc_none": "O domínio do remetente não tem política DMARC configurada"
}
},
"technical_details": "Detalhes Técnicos",
"message_id_label": "Message-ID:",
"reply_to_label": "Responder para:",
@@ -270,7 +302,24 @@
"select_calendar": "Selecionar calendário",
"already_in_calendar": "Já está no seu calendário"
},
"keyboard_shortcuts": "Atalhos de teclado (?)"
"keyboard_shortcuts": "Atalhos de teclado (?)",
"mobile_actions": {
"reply": "Responder",
"reply_all": "Responder a todos",
"archive": "Arquivar",
"delete": "Excluir",
"more": "Mais",
"forward": "Encaminhar",
"move_to": "Mover para...",
"star": "Favoritar",
"mark_unread": "Marcar como não lido",
"spam": "Denunciar spam"
},
"sender_info": {
"add_to_contacts": "Adicionar aos contatos",
"view_all_emails": "Ver todos os e-mails deste remetente",
"no_contact": "Não está nos seus contatos"
}
},
"email_composer": {
"new_message": "Nova Mensagem",
@@ -469,6 +518,7 @@
"list_density": {
"label": "Densidade da Lista",
"description": "Controle o espaçamento nas listas de e-mail",
"extra_compact": "Extra compacto",
"compact": "Compacto",
"regular": "Regular",
"comfortable": "Confortável"
+58
View File
@@ -89,6 +89,13 @@ interface EmailStore {
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
// Tag counts
tagCounts: Record<string, number>;
fetchTagCounts: (client: JMAPClient) => Promise<void>;
// Empty folder
emptyFolder: (client: JMAPClient, mailboxId: string, onProgress?: (deleted: number, total: number) => void) => Promise<void>;
// Mock data for demo
loadMockData: () => void;
}
@@ -122,6 +129,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isAdvancedSearchOpen: false,
searchAbortController: null,
// Tag counts
tagCounts: {},
// Spam undo cache
spamUndoCache: new Map(),
@@ -990,6 +1000,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Handle Email state changes - refresh current mailbox
if (accountChanges.Email) {
await get().refreshCurrentMailbox(client);
get().fetchTagCounts(client);
}
// Handle Mailbox state changes - refresh mailbox list
@@ -1157,6 +1168,53 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ threadEmailsCache: newCache });
},
fetchTagCounts: async (client) => {
try {
const tags = ["red", "orange", "yellow", "green", "blue", "purple", "pink"];
const tagCounts = await client.queryTagCounts(tags);
set({ tagCounts });
} catch (error) {
console.error("Failed to fetch tag counts:", error);
}
},
emptyFolder: async (client, mailboxId, onProgress) => {
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
const jmapMailboxId = mailbox?.originalId || mailboxId;
let totalDeleted = 0;
let totalEmails = 0;
const firstBatch = await client.queryMailboxEmailIds(jmapMailboxId, 500, 0);
totalEmails = firstBatch.total;
if (totalEmails === 0) return;
let ids = firstBatch.ids;
while (ids.length > 0) {
try {
await client.batchDeleteEmails(ids);
totalDeleted += ids.length;
onProgress?.(totalDeleted, totalEmails);
} catch {
throw new Error(`Deleted ${totalDeleted} of ${totalEmails} emails before failure`);
}
if (totalDeleted >= totalEmails) break;
const nextBatch = await client.queryMailboxEmailIds(jmapMailboxId, 500, 0);
ids = nextBatch.ids;
if (ids.length === 0) break;
}
await get().fetchMailboxes(client);
if (get().selectedMailbox === mailboxId) {
set({ emails: [], totalEmails: 0, hasMoreEmails: false });
}
},
loadMockData: () => {
const mockEmails: Email[] = [
{
+25 -2
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type FontSize = 'small' | 'medium' | 'large';
export type ListDensity = 'compact' | 'regular' | 'comfortable';
export type ListDensity = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
export type DeleteAction = 'trash' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll';
export type DateFormat = 'regional' | 'iso' | 'custom';
@@ -41,6 +41,9 @@ interface SettingsState {
calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean;
// Layout
sidebarWidth: number;
// Advanced
debugMode: boolean;
@@ -90,6 +93,9 @@ const DEFAULT_SETTINGS = {
calendarNotificationsEnabled: true,
calendarNotificationSound: true,
// Layout
sidebarWidth: 256,
// Advanced
debugMode: false,
};
@@ -116,6 +122,11 @@ export const useSettingsStore = create<SettingsState>()(
if (key === 'animationsEnabled') {
applyAnimations(value as boolean);
}
// Apply sidebar width to document root
if (key === 'sidebarWidth') {
applySidebarWidth(value as number);
}
},
resetToDefaults: () => {
@@ -123,6 +134,7 @@ export const useSettingsStore = create<SettingsState>()(
applyFontSize(DEFAULT_SETTINGS.fontSize);
applyListDensity(DEFAULT_SETTINGS.listDensity);
applyAnimations(DEFAULT_SETTINGS.animationsEnabled);
applySidebarWidth(DEFAULT_SETTINGS.sidebarWidth);
},
exportSettings: () => {
@@ -146,6 +158,7 @@ export const useSettingsStore = create<SettingsState>()(
sessionTimeout: state.sessionTimeout,
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound,
sidebarWidth: state.sidebarWidth,
debugMode: state.debugMode,
};
return JSON.stringify(settings, null, 2);
@@ -171,6 +184,7 @@ export const useSettingsStore = create<SettingsState>()(
applyFontSize(get().fontSize);
applyListDensity(get().listDensity);
applyAnimations(get().animationsEnabled);
applySidebarWidth(get().sidebarWidth);
return true;
} catch (error) {
@@ -224,12 +238,20 @@ function applyListDensity(density: ListDensity) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const densityMap = {
const densityMap: Record<ListDensity, string> = {
'extra-compact': '28px',
compact: '32px',
regular: '48px',
comfortable: '64px',
};
root.style.setProperty('--list-item-height', densityMap[density]);
root.dataset.density = density;
}
function applySidebarWidth(width: number) {
if (typeof document === 'undefined') return;
document.documentElement.style.setProperty('--sidebar-width', `${width}px`);
}
function applyAnimations(enabled: boolean) {
@@ -249,4 +271,5 @@ if (typeof window !== 'undefined') {
applyFontSize(store.fontSize);
applyListDensity(store.listDensity);
applyAnimations(store.animationsEnabled);
applySidebarWidth(store.sidebarWidth);
}