diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f99fc3..1a402a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,9 +4,26 @@
### Features
+- **Sandboxed email rendering**: Rich HTML emails (newsletters, tables) now render in a sandboxed iframe for CSS isolation — prevents email styles from bleeding into the app UI
+- **API retry with backoff**: JMAP requests now automatically retry on transient failures (503, 429, network errors) with exponential backoff
+- **Mobile action bar**: Bottom toolbar with Reply, Reply All, Archive, Delete, and More actions when viewing emails on mobile
+- **Long-press context menu**: Long-press on email list items triggers the context menu on touch devices, with haptic feedback
+- **Tag counts in sidebar**: Collapsible Tags section shows color-coded tags with email counts
+- **Empty folder**: One-click empty for Junk and Trash folders with confirmation and batch deletion progress
+- **Extra-compact density**: New density option that hides avatars and previews for maximum information density (44px touch targets on mobile)
+- **Security tooltips**: SPF, DKIM, and DMARC indicators now show plain-language explanations on hover
+- **Resizable sidebars**: Drag the sidebar edge to resize (180-400px), with keyboard and touch support, persisted in settings
+- **Sender info panel**: Click a sender's name to see their contact info, add to contacts, or search all their emails
- **OAuth-only mode**: New `OAUTH_ONLY` env var hides the username/password form and only shows SSO login (#32)
- **OAuth retry**: Added retry button when OAuth discovery fails, preventing dead-end login pages
+### Improvements
+
+- Mobile/tablet layout transitions are now CSS-first — no more blink on orientation change
+- More Actions dropdown works on touch devices (was hover-only)
+- Touch-friendly context menu submenus (tap-to-expand instead of hover)
+- Wide HTML emails are horizontally scrollable in iframe view
+
## 1.1.4 (2026-03-16)
### Fixes
diff --git a/README.md b/README.md
index 49a0997..7c2bb7f 100644
--- a/README.md
+++ b/README.md
@@ -13,23 +13,28 @@ Stalwart is a mail server written in Rust with native JMAP support, not IMAP/SMT
### Email
- Read, compose, reply, reply-all, and forward
-- HTML rendering with DOMPurify sanitization
+- HTML rendering with DOMPurify sanitization and sandboxed iframe for complex emails
- Attachment upload and download
- Draft auto-save with discard confirmation
- Threading with inline expansion
- Mark as read/unread, star/unstar
- Archive and delete with configurable behavior
-- Color tags/labels
+- Color tags/labels with sidebar counts
- Search with JMAP filter panel, search chips, cross-mailbox queries
- Virtual scrolling for large lists
+- Empty folder (one-click empty Junk/Trash with batch progress)
+- Sender info panel (click sender name to view contact, add to contacts, search)
+- API retry with exponential backoff for transient failures
### Interface
- Three-pane layout with dark and light themes
-- Responsive (desktop sidebar + mobile bottom tab bar)
+- Responsive (desktop sidebar + mobile bottom tab bar + mobile action bar)
- Keyboard shortcuts
- Drag-and-drop email organization
-- Right-click context menus
+- Right-click context menus (long-press on touch devices)
+- Extra-compact, compact, regular, and comfortable density options
+- Resizable sidebar (drag, touch, keyboard)
- Animations that respect `prefers-reduced-motion`
- Infinite scroll pagination
- Toast notifications with undo support
@@ -107,7 +112,7 @@ Stalwart is a mail server written in Rust with native JMAP support, not IMAP/SMT
- External content blocked by default
- Trusted senders list for automatic image loading
- HTML sanitization (DOMPurify)
-- SPF/DKIM/DMARC status indicators
+- SPF/DKIM/DMARC status indicators with plain-language tooltips
- Session-based auth, no password storage by default
- TOTP two-factor authentication
- "Remember me" with AES-256-GCM encrypted httpOnly cookie (opt-in)
diff --git a/ROADMAP.md b/ROADMAP.md
index fab6bb0..7363882 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -203,6 +203,27 @@ This document tracks the development status and planned features for JMAP Webmai
### Email Display
- [x] Proper email layout without horizontal scroll or clipping
- [x] Blocked image container collapsing (no empty spaces in newsletters)
+- [x] Sandboxed iframe rendering for rich HTML emails (CSS isolation from app)
+- [x] Adaptive rendering: iframe for complex HTML, inline for plain text/simple HTML
+- [x] SPF/DKIM/DMARC tooltips with plain-language security explanations
+- [x] Expandable sender info panel (contact lookup, add-to-contacts, search sender)
+
+### Mobile & Touch
+- [x] Bottom action bar for email actions (Reply, Reply All, Archive, Delete, More)
+- [x] Long-press context menu with haptic feedback (300ms, cancels on scroll)
+- [x] Touch-friendly context menu submenus (tap-to-expand on touch devices)
+- [x] CSS-first responsive layout (instant orientation changes, no JS-driven blink)
+- [x] Click-to-toggle more-actions dropdown (was hover-only)
+
+### Sidebar & Density
+- [x] Tag counts section with color-coded tags and email counts
+- [x] Empty folder option for Junk/Trash (batch delete with progress)
+- [x] Extra-compact density option (28px rows, 44px on touch devices)
+- [x] Resizable sidebar via drag handle (180-400px, keyboard accessible, persisted)
+
+### API Robustness
+- [x] Exponential backoff retry for transient JMAP failures (429, 502, 503, 504)
+- [x] Opt-out for blob downloads, uploads, and polling (no double-request risk)
### Testing
- [x] Unit tests for validation utilities (57 tests)
@@ -223,6 +244,8 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Unit tests for calendar participants (26 tests)
- [x] Unit tests for template utilities (48 tests)
- [x] Unit tests for OAuth PKCE and discovery (14 tests)
+- [x] Unit tests for iframe rendering detection (12 tests)
+- [x] Unit tests for API retry with backoff (9 tests)
- [x] XSS attack vector testing
- [x] Playwright E2E framework setup
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 8568f1b..65aa1b6 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -16,6 +16,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store";
+import { useContactStore } from "@/stores/contact-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { debug } from "@/lib/debug";
@@ -91,8 +92,11 @@ export default function Home() {
clearSearchFilters,
toggleAdvancedSearch,
advancedSearch,
+ fetchTagCounts,
} = useEmailStore();
+ const contactStore = useContactStore();
+
// Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({
onNextEmail: () => {
@@ -115,13 +119,7 @@ export default function Home() {
// Email is already opened when selected
},
onCloseEmail: () => {
- selectEmail(null);
- if (isMobile) {
- setActiveView("list");
- }
- if (isTablet) {
- setTabletListVisible(true);
- }
+ dismissViewer();
},
onReply: () => {
if (selectedEmail) handleReply();
@@ -267,6 +265,9 @@ export default function Home() {
await fetchEmails(client);
}
+ // Fetch tag counts in the background
+ fetchTagCounts(client);
+
// Setup push notifications after successful data load
try {
// Register state change callback
@@ -298,7 +299,7 @@ export default function Home() {
client.closePushNotifications();
}
};
- }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, handleStateChange, setPushConnected]);
+ }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
// Handle mark-as-read with delay based on settings
useEffect(() => {
@@ -367,6 +368,13 @@ export default function Home() {
};
}, [isMobile, isTablet, sidebarOpen]);
+ // Reset tablet list visibility when crossing to desktop
+ useEffect(() => {
+ if (!isMobile && !isTablet) {
+ setTabletListVisible(true);
+ }
+ }, [isMobile, isTablet, setTabletListVisible]);
+
const handleEmailSend = async (data: {
to: string[];
cc: string[];
@@ -417,12 +425,18 @@ export default function Home() {
setShowComposer(true);
};
+ const dismissViewer = () => {
+ selectEmail(null);
+ if (isMobile) setActiveView("list");
+ if (isTablet) setTabletListVisible(true);
+ };
+
const handleDelete = async () => {
if (!client || !selectedEmail) return;
try {
await deleteEmail(client, selectedEmail.id);
- selectEmail(null);
+ dismissViewer();
} catch (error) {
console.error("Failed to delete email:", error);
}
@@ -436,7 +450,7 @@ export default function Home() {
if (archiveMailbox) {
try {
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
- selectEmail(null);
+ dismissViewer();
} catch (error) {
console.error("Failed to archive email:", error);
}
@@ -655,11 +669,6 @@ export default function Home() {
setActiveView("viewer");
}
- // On tablet, hide the list to maximize viewer space
- if (isTablet) {
- setTabletListVisible(false);
- }
-
// Fetch the full content
try {
// Find selected mailbox to determine accountId (for shared folders)
@@ -670,7 +679,7 @@ export default function Home() {
const fullEmail = await client.getEmail(email.id, accountId);
if (fullEmail) {
selectEmail(fullEmail);
- // Mark-as-read logic is now handled by useEffect
+ if (isTablet) setTabletListVisible(false);
}
} catch (error) {
console.error('Failed to fetch email content:', error);
@@ -790,12 +799,12 @@ export default function Home() {
"flex flex-col h-full bg-background border-r border-border",
// Mobile: full width, hidden when viewing email
"max-md:flex-1 max-md:border-r-0",
- isMobile && activeView !== "list" && "max-md:hidden",
- // Tablet/Desktop: fixed width with collapse animation
- "md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm",
- "transition-all duration-200 ease-out",
- // Tablet: collapse when email selected
- isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
+ activeView !== "list" && "max-md:hidden",
+ // Tablet: full width when no email, fixed width when viewing
+ !selectedEmail ? "md:flex-1 md:border-r-0" : "md:w-80 lg:w-96 md:flex-shrink-0",
+ "md:shadow-sm",
+ // Collapse list when viewing email on tablet (tabletListVisible only toggled in tablet range)
+ selectedEmail && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)}
>
{/* Mobile Header for List View */}
@@ -886,9 +895,11 @@ export default function Home() {
"flex flex-col h-full bg-background",
// Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30",
- isMobile && activeView !== "viewer" && "max-md:hidden",
- // Tablet/Desktop: flex grow, min-w-0 allows truncation of long subjects
- "md:flex-1 md:min-w-0 md:relative"
+ activeView !== "viewer" && "max-md:hidden",
+ // Tablet/Desktop: flex grow
+ "md:flex-1 md:min-w-0 md:relative",
+ // Hide viewer when no email selected (list takes full width)
+ !selectedEmail && "max-lg:hidden"
)}
>
{/* Mobile Conversation View - shown when thread is selected on mobile */}
@@ -943,6 +954,30 @@ export default function Home() {
selectEmail(null);
}}
onShowShortcuts={() => setShowShortcutsModal(true)}
+ onSearchSender={(email) => {
+ const query = `from:${email}`;
+ setSearchQuery(query);
+ if (client) {
+ searchEmails(client, query);
+ }
+ }}
+ onAddContact={(name, email) => {
+ if (client && contactStore.supportsSync) {
+ contactStore.createContact(client, {
+ kind: 'individual',
+ name: name ? { components: [{ kind: 'given', value: name }], isOrdered: true } : undefined,
+ emails: { email0: { address: email } },
+ });
+ } else {
+ contactStore.addLocalContact({
+ id: `local-${crypto.randomUUID()}`,
+ addressBookIds: {},
+ kind: 'individual',
+ name: name ? { components: [{ kind: 'given', value: name }], isOrdered: true } : undefined,
+ emails: { email0: { address: email } },
+ });
+ }
+ }}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
diff --git a/app/globals.css b/app/globals.css
index 0ccda18..691192f 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -387,6 +387,13 @@ body {
animation: slide-in-from-left 0.3s ease-out;
}
+/* Extra-compact density: enforce minimum touch target on touch devices */
+@media (pointer: coarse) {
+ [data-density="extra-compact"] .email-list-item {
+ min-height: 44px;
+ }
+}
+
/* Reduced motion: respect user OS preference */
@media (prefers-reduced-motion: reduce) {
*,
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx
index 58f57ce..2d0515e 100644
--- a/components/email/email-list-item.tsx
+++ b/components/email/email-list-item.tsx
@@ -10,6 +10,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
+import { useLongPress } from "@/hooks/use-long-press";
import { EmailIdentityBadge } from "./email-identity-badge";
interface EmailListItemProps {
@@ -45,6 +46,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
+ const listDensity = useSettingsStore((state) => state.listDensity);
+ const isExtraCompact = listDensity === 'extra-compact';
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
@@ -68,11 +71,28 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
onContextMenu?.(e, email);
};
+ const longPressHandlers = useLongPress({
+ onLongPress: (e) => {
+ if (onContextMenu) {
+ const touch = e.touches?.[0] || e.changedTouches?.[0];
+ if (touch) {
+ const syntheticEvent = {
+ preventDefault: () => {},
+ clientX: touch.clientX,
+ clientY: touch.clientY,
+ } as unknown as React.MouseEvent;
+ onContextMenu(syntheticEvent, email);
+ }
+ }
+ },
+ });
+
return (
+ {!isExtraCompact && (
+
+ )}
{/* Content */}
@@ -151,7 +173,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
)}
- {email.hasAttachment && (
+ {!isExtraCompact && email.hasAttachment && (
)}
@@ -176,8 +198,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
{email.subject || t('no_subject')}
- {/* Third Line: Preview (controlled by showPreview setting) */}
- {showPreview && (
+ {/* Third Line: Preview (controlled by showPreview setting, hidden in extra-compact) */}
+ {showPreview && !isExtraCompact && (
state.showPreview);
const estimateSize = useCallback(() => {
- const base = { compact: 72, regular: 88, comfortable: 104 }[listDensity];
- return showPreview ? base + 40 : base;
+ const base = { 'extra-compact': 44, compact: 72, regular: 88, comfortable: 104 }[listDensity];
+ return listDensity === 'extra-compact' ? base : (showPreview ? base + 40 : base);
}, [listDensity, showPreview]);
const virtualizer = useVirtualizer({
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 0c8846f..c60ac4b 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -3,7 +3,8 @@
import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify";
import { Email } from "@/lib/jmap/types";
-import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
+import { hasRichFormatting, needsIframeRendering, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
+import { SandboxedEmailFrame } from "./sandboxed-email-frame";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn } from "@/lib/utils";
@@ -57,9 +58,11 @@ import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { EmailIdentityBadge } from "./email-identity-badge";
+import { MobileActionBar } from "./mobile-action-bar";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { findCalendarAttachment } from "@/lib/calendar-invitation";
+import { SenderInfoPanel } from "./sender-info-panel";
interface EmailViewerProps {
email: Email | null;
@@ -78,6 +81,8 @@ interface EmailViewerProps {
onUndoSpam?: () => void;
onBack?: () => void;
onShowShortcuts?: () => void;
+ onSearchSender?: (email: string) => void;
+ onAddContact?: (name: string, email: string) => void;
currentUserEmail?: string;
currentUserName?: string;
currentMailboxRole?: string;
@@ -173,6 +178,8 @@ export function EmailViewer({
onUndoSpam,
onBack,
onShowShortcuts,
+ onSearchSender,
+ onAddContact,
currentUserEmail,
currentUserName,
currentMailboxRole,
@@ -200,7 +207,7 @@ export function EmailViewer({
];
// Tablet list visibility
- const { isTablet } = useDeviceDetection();
+ const { isMobile, isTablet } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
@@ -211,6 +218,8 @@ export function EmailViewer({
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
const [showSourceModal, setShowSourceModal] = useState(false);
+ const [showSenderInfo, setShowSenderInfo] = useState(false);
+ const [showMoreActions, setShowMoreActions] = useState(false);
const currentColor = getCurrentColor(email?.keywords);
const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState>(
() => {
@@ -237,6 +246,8 @@ export function EmailViewer({
setQuickReplyText("");
setIsQuickReplyFocused(false);
setShowSourceModal(false);
+ setShowSenderInfo(false);
+ setShowMoreActions(false);
}, [email?.id, externalContentPolicy]);
// Generate email source for viewing
@@ -503,7 +514,8 @@ export function EmailViewer({
return {
html: cleanHtml,
- isHtml: true
+ isHtml: true,
+ useIframe: needsIframeRendering(htmlContent),
};
}
@@ -859,25 +871,29 @@ export function EmailViewer({
{/* More Actions Dropdown */}
-
+
-
+ {showMoreActions && (
+ <>
+
setShowMoreActions(false)} onKeyDown={(e) => e.key === 'Escape' && setShowMoreActions(false)} role="presentation" />
+
{onShowShortcuts && (
+
+ >
+ )}
@@ -924,21 +942,31 @@ export function EmailViewer({
{/* Sender Info - Desktop only (hidden on mobile/tablet, they see it in scrollable content) */}
-
-
-
+
+
+
setShowSenderInfo(!showSenderInfo)}
+ className="cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0"
+ >
+
+
{/* Sender line with compact badges */}
-
+ setShowSenderInfo(!showSenderInfo)}
+ className="font-semibold text-foreground hover:text-primary transition-colors cursor-pointer"
+ >
{sender?.name || sender?.email || t('unknown_sender')}
-
+
@@ -1008,94 +1036,109 @@ export function EmailViewer({
{/* SPF Check */}
{email.authenticationResults.spf && (
-
-
- {getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' &&
-
}
- {getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' &&
-
}
- {getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' &&
-
}
- {getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' &&
-
}
-
-
SPF
-
- {email.authenticationResults.spf.result}
+
+
+
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'check' &&
+
}
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'x' &&
+
}
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'alert' &&
+
}
+ {getSecurityStatus(email.authenticationResults.spf.result).icon === 'minus' &&
+
}
+
+
SPF
+
+ {email.authenticationResults.spf.result}
+
+ {email.authenticationResults.spf.domain && (
+
+ {email.authenticationResults.spf.domain}
+
+ )}
+
+
+ {t(`security.tooltip.spf_${email.authenticationResults.spf.result}`)}
- {email.authenticationResults.spf.domain && (
-
- {email.authenticationResults.spf.domain}
-
- )}
)}
{/* DKIM Check */}
{email.authenticationResults.dkim && (
-
-
- {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' &&
-
}
- {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' &&
-
}
- {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' &&
-
}
- {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' &&
-
}
-
-
DKIM
-
- {email.authenticationResults.dkim.result}
+
+
+
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'check' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'x' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'alert' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dkim.result).icon === 'minus' &&
+
}
+
+
DKIM
+
+ {email.authenticationResults.dkim.result}
+
+ {email.authenticationResults.dkim.domain && (
+
+ {email.authenticationResults.dkim.domain}
+
+ )}
+
+
+ {t(`security.tooltip.dkim_${email.authenticationResults.dkim.result}`)}
- {email.authenticationResults.dkim.domain && (
-
- {email.authenticationResults.dkim.domain}
-
- )}
)}
{/* DMARC Check */}
{email.authenticationResults.dmarc && (
-
-
- {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' &&
-
}
- {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' &&
-
}
- {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' &&
-
}
- {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' &&
-
}
-
-
DMARC
-
- {email.authenticationResults.dmarc.result}
+
+
+
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'check' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'x' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'alert' &&
+
}
+ {getSecurityStatus(email.authenticationResults.dmarc.result).icon === 'minus' &&
+
}
+
+
DMARC
+
+ {email.authenticationResults.dmarc.result}
+
+ {email.authenticationResults.dmarc.policy && (
+
+ Policy: {email.authenticationResults.dmarc.policy}
+
+ )}
+
+
+ {t(`security.tooltip.dmarc_${email.authenticationResults.dmarc.result}`)}
- {email.authenticationResults.dmarc.policy && (
-
- Policy: {email.authenticationResults.dmarc.policy}
-
- )}
)}
@@ -1274,6 +1317,13 @@ export function EmailViewer({
+ {showSenderInfo && sender && (
+
onSearchSender?.(email)}
+ onAddContact={(name, email) => onAddContact?.(name, email)}
+ />
+ )}
{/* Email Content Area */}
@@ -1281,18 +1331,28 @@ export function EmailViewer({
{/* Mobile/Tablet Sender Info - scrolls with content */}
-
+
setShowSenderInfo(!showSenderInfo)}
+ className="cursor-pointer hover:opacity-80 transition-opacity flex-shrink-0"
+ >
+
+
{/* Mobile 2-line layout */}
-
+ setShowSenderInfo(!showSenderInfo)}
+ className="text-sm font-semibold text-foreground hover:text-primary transition-colors cursor-pointer"
+ >
{sender?.name || sender?.email || t('unknown_sender')}
-
+
@@ -1323,6 +1383,13 @@ export function EmailViewer({
)}
+ {showSenderInfo && sender && (
+
onSearchSender?.(email)}
+ onAddContact={(name, email) => onAddContact?.(name, email)}
+ />
+ )}
{/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */}
@@ -1476,7 +1543,9 @@ export function EmailViewer({
{/* Email Body */}
- {emailContent.isHtml ? (
+ {emailContent.isHtml && emailContent.useIframe ? (
+
+ ) : emailContent.isHtml ? (
+ {/* Mobile bottom action bar */}
+ {email && (isMobile || isTablet) && (
+
onReply?.()}
+ onReplyAll={() => onReplyAll?.()}
+ onArchive={() => onArchive?.()}
+ onDelete={() => onDelete?.()}
+ onForward={() => onForward?.()}
+ onStar={() => onToggleStar?.()}
+ onMarkUnread={() => email && onMarkAsRead?.(email.id, false)}
+ onSpam={() => onMarkAsSpam?.()}
+ />
+ )}
+
{/* Email Source Modal */}
{showSourceModal && email && (
void;
+ onReplyAll: () => void;
+ onArchive: () => void;
+ onDelete: () => void;
+ onForward: () => void;
+ onStar: () => void;
+ onMarkUnread: () => void;
+ onSpam: () => void;
+}
+
+export function MobileActionBar({
+ onReply, onReplyAll, onArchive, onDelete,
+ onForward, onStar, onMarkUnread, onSpam,
+}: MobileActionBarProps) {
+ const t = useTranslations('email_viewer.mobile_actions');
+ const [showMore, setShowMore] = useState(false);
+
+ return (
+ <>
+ {showMore && (
+
setShowMore(false)}
+ onKeyDown={(e) => e.key === 'Escape' && setShowMore(false)}
+ />
+ )}
+
+ {showMore && (
+
+
+ {[
+ { icon: Forward, label: t('forward'), action: onForward },
+ { icon: Star, label: t('star'), action: onStar },
+ { icon: MailOpen, label: t('mark_unread'), action: onMarkUnread },
+ { icon: ShieldAlert, label: t('spam'), action: onSpam },
+ ].map(({ icon: Icon, label, action }) => (
+ { action(); setShowMore(false); }}
+ role="menuitem"
+ >
+
+ {label}
+
+ ))}
+
+
setShowMore(false)}
+ >
+
+
+
+ )}
+
+
+ {[
+ { 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 }) => (
+
+
+ {label}
+
+ ))}
+
+ >
+ );
+}
diff --git a/components/email/sandboxed-email-frame.tsx b/components/email/sandboxed-email-frame.tsx
new file mode 100644
index 0000000..ed54e29
--- /dev/null
+++ b/components/email/sandboxed-email-frame.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useRef, useEffect, useCallback } from "react";
+import { generateIframeStylesheet } from "@/lib/color-transform";
+
+interface SandboxedEmailFrameProps {
+ html: string;
+ className?: string;
+}
+
+function wrapHtmlForIframe(sanitizedHtml: string): string {
+ return `
+
+
+
+
+ ${generateIframeStylesheet()}
+
+${sanitizedHtml}
+`;
+}
+
+export function SandboxedEmailFrame({ html, className }: SandboxedEmailFrameProps) {
+ const iframeRef = useRef
(null);
+
+ const updateHeight = useCallback(() => {
+ const iframe = iframeRef.current;
+ if (!iframe?.contentDocument?.body) return;
+ const height = iframe.contentDocument.body.scrollHeight;
+ iframe.style.height = `${height + 16}px`;
+ }, []);
+
+ useEffect(() => {
+ const iframe = iframeRef.current;
+ if (!iframe) return;
+
+ let observer: ResizeObserver | null = null;
+
+ const handleLoad = () => {
+ updateHeight();
+
+ const doc = iframe.contentDocument;
+ if (!doc?.body) return;
+
+ observer = new ResizeObserver(updateHeight);
+ observer.observe(doc.body);
+
+ doc.addEventListener('click', (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ const anchor = target.closest('a');
+ if (anchor?.href) {
+ e.preventDefault();
+ window.open(anchor.href, '_blank', 'noopener,noreferrer');
+ }
+ });
+ };
+
+ iframe.addEventListener('load', handleLoad);
+ return () => {
+ iframe.removeEventListener('load', handleLoad);
+ observer?.disconnect();
+ };
+ }, [html, updateHeight]);
+
+ return (
+
+ );
+}
diff --git a/components/email/sender-info-panel.tsx b/components/email/sender-info-panel.tsx
new file mode 100644
index 0000000..faee0a6
--- /dev/null
+++ b/components/email/sender-info-panel.tsx
@@ -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 (
+
+
+
+
+
+
+ {sender.name || sender.email}
+
+ {sender.name && (
+
+ {sender.email}
+
+ )}
+
+
+ {matchedContact ? (
+
+ {orgName && (
+ {orgName}
+ )}
+ {!orgName && (
+ {getContactDisplayName(matchedContact)}
+ )}
+
+ ) : (
+
+ {t("no_contact")}
+
+ )}
+
+
+ {!matchedContact && (
+ onAddContact(sender.name || "", sender.email)}
+ >
+
+ {t("add_to_contacts")}
+
+ )}
+ onSearch(sender.email)}
+ >
+
+ {t("view_all_emails")}
+
+
+
+
+
+ );
+}
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 949a52f..0ba93fd 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -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 */}
-
+ {emailContent.isHtml && emailContent.useIframe ? (
+
+ ) : (
+
+ )}
{/* Attachments */}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 3b2b3bd..cd8501f 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -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;
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({
<>
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
= {
+ 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 (
+
+
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"
+ >
+
+ {t("tags.title")}
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ {expanded && (
+
+ {tags.map(([color, count]) => (
+ onSearch?.(`keyword:$color:${color}`)}
+ className="flex items-center w-full px-4 py-1.5 text-sm hover:bg-muted transition-colors text-foreground"
+ >
+
+ {color}
+
+ {count}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function EmptyFolderConfirmDialog({
+ mailbox,
+ onConfirm,
+ onCancel,
+}: {
+ mailbox: { name: string; totalEmails: number };
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ const t = useTranslations("sidebar");
+
+ return (
+
+
e.key === "Escape" && onCancel()}
+ />
+
+
{t("empty_folder.title")}
+
+ {t("empty_folder.confirm", {
+ count: mailbox.totalEmails,
+ folder: mailbox.name,
+ })}
+
+
+
+ {t("empty_folder.cancel")}
+
+
+ {t("empty_folder.title")}
+
+
+
+
+ );
+}
+
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
>(new Set());
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number; mailbox: Mailbox } | null>(null);
+ const [emptyFolderTarget, setEmptyFolderTarget] = useState(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 */}
@@ -493,14 +658,56 @@ export function Sidebar({
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
+ onMailboxContextMenu={handleMailboxContextMenu}
isCollapsed={isCollapsed}
/>
))}
>
)}
+
+ {/* Tags Section */}
+
+ {/* Mailbox Context Menu */}
+ {contextMenu && (
+ <>
+ setContextMenu(null)}
+ onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
+ />
+
+ {
+ setEmptyFolderTarget(contextMenu.mailbox);
+ setContextMenu(null);
+ }}
+ className="flex items-center w-full px-3 py-2 text-sm text-destructive hover:bg-muted transition-colors"
+ >
+
+ {t("empty_folder.title")}
+
+
+ >
+ )}
+
+ {/* Empty Folder Confirmation Dialog */}
+ {emptyFolderTarget && (
+
setEmptyFolderTarget(null)}
+ />
+ )}
+
{/* Footer: Storage Quota + Sign Out + Push Status */}
@@ -546,6 +753,23 @@ export function Sidebar({
)}
+
+ {/* Resize Handle */}
+ {!isCollapsed && (
+
+ )}
);
}
diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx
index be32c17..de36233 100644
--- a/components/settings/appearance-settings.tsx
+++ b/components/settings/appearance-settings.tsx
@@ -49,9 +49,10 @@ export function AppearanceSettings() {
- 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') },
diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx
index 9c6a77c..fd9bf21 100644
--- a/components/ui/context-menu.tsx
+++ b/components/ui/context-menu.tsx
@@ -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(null);
const subMenuRef = useRef(null);
const closeTimerRef = useRef | 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 (
{Icon &&
}
{label}
diff --git a/hooks/use-long-press.ts b/hooks/use-long-press.ts
new file mode 100644
index 0000000..e81bac9
--- /dev/null
+++ b/hooks/use-long-press.ts
@@ -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
| null>(null);
+ const startPos = useRef<{ x: number; y: number } | null>(null);
+ const targetRef = useRef(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 };
+}
diff --git a/hooks/use-resize-handle.ts b/hooks/use-resize-handle.ts
new file mode 100644
index 0000000..8558b57
--- /dev/null
+++ b/hooks/use-resize-handle.ts
@@ -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 };
+}
diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts
index c7607cb..a7f1be9 100644
--- a/lib/__tests__/email-sanitization.test.ts
+++ b/lib/__tests__/email-sanitization.test.ts
@@ -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('')).toBe(true);
+ });
+
+ it('returns true for HTML with style tags', () => {
+ expect(needsIframeRendering('Text
')).toBe(true);
+ });
+
+ it('returns true for HTML with background in inline styles', () => {
+ expect(needsIframeRendering('Content
')).toBe(true);
+ });
+
+ it('returns true for HTML with background-image in inline styles', () => {
+ expect(needsIframeRendering('Cell
')).toBe(true);
+ });
+
+ it('returns true for HTML with link tags', () => {
+ expect(needsIframeRendering('Text
')).toBe(true);
+ });
+
+ it('returns false for plain text converted to HTML', () => {
+ expect(needsIframeRendering('
Hello world
How are you?')).toBe(false);
+ });
+
+ it('returns false for simple formatting tags', () => {
+ expect(needsIframeRendering('Bold and italic text
')).toBe(false);
+ });
+
+ it('returns false for blockquotes', () => {
+ expect(needsIframeRendering('Quoted text
')).toBe(false);
+ });
+
+ it('returns false for lists', () => {
+ expect(needsIframeRendering('')).toBe(false);
+ });
+
+ it('returns false for headings', () => {
+ expect(needsIframeRendering('Title
Body
')).toBe(false);
+ });
+
+ it('returns false for simple inline styles without background', () => {
+ expect(needsIframeRendering('Text
')).toBe(false);
+ });
+
+ it('returns false for empty string', () => {
+ expect(needsIframeRendering('')).toBe(false);
+ });
+ });
});
diff --git a/lib/__tests__/jmap-retry.test.ts b/lib/__tests__/jmap-retry.test.ts
new file mode 100644
index 0000000..3d20173
--- /dev/null
+++ b/lib/__tests__/jmap-retry.test.ts
@@ -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();
+ });
+});
diff --git a/lib/color-transform.ts b/lib/color-transform.ts
index e6b3072..b566088 100644
--- a/lib/color-transform.ts
+++ b/lib/color-transform.ts
@@ -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 `
+
+ `;
+}
diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts
index 54d64e9..c8e0bb8 100644
--- a/lib/email-sanitization.ts
+++ b/lib/email-sanitization.ts
@@ -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
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index ee04300..61b0bcc 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -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[1]): Promise {
+ private async authenticatedFetch(
+ url: string,
+ init?: Parameters[1],
+ options?: { retry?: boolean }
+ ): Promise {
const headers = { ...init?.headers as Record, '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> {
+ 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 = {};
+
+ 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 {
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 {
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();
diff --git a/lib/jmap/retry.ts b/lib/jmap/retry.ts
new file mode 100644
index 0000000..b7b7cd7
--- /dev/null
+++ b/lib/jmap/retry.ts
@@ -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,
+ options: RetryOptions = {}
+): Promise {
+ 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');
+}
diff --git a/locales/de/common.json b/locales/de/common.json
index 72adee7..0c11410 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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"
diff --git a/locales/en/common.json b/locales/en/common.json
index 69f7758..6e9afb8 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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"
diff --git a/locales/es/common.json b/locales/es/common.json
index 82adfb1..2b6006b 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -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"
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 1d10b07..43aeb10 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -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"
diff --git a/locales/it/common.json b/locales/it/common.json
index bb949e2..0f3780a 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -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"
diff --git a/locales/ja/common.json b/locales/ja/common.json
index ab10247..cbd2f96 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -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": "ゆったり"
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 77a49a9..2346a5c 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -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"
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 0ff1111..03b6fe5 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -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"
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 9becd35..317062b 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -89,6 +89,13 @@ interface EmailStore {
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
+ // Tag counts
+ tagCounts: Record;
+ fetchTagCounts: (client: JMAPClient) => Promise;
+
+ // Empty folder
+ emptyFolder: (client: JMAPClient, mailboxId: string, onProgress?: (deleted: number, total: number) => void) => Promise;
+
// Mock data for demo
loadMockData: () => void;
}
@@ -122,6 +129,9 @@ export const useEmailStore = create((set, get) => ({
isAdvancedSearchOpen: false,
searchAbortController: null,
+ // Tag counts
+ tagCounts: {},
+
// Spam undo cache
spamUndoCache: new Map(),
@@ -990,6 +1000,7 @@ export const useEmailStore = create((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((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[] = [
{
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index 70a530e..0509ff7 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -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()(
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()(
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()(
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()(
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 = {
+ '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);
}