mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-22 16:01:12 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user