feat: v1.4.0 - folder management, mail multi-selection, bug fixes

New features:
- Folder management with context menu, inline editing, drag-and-drop (#44)
- Mail multi-selection with batch move/delete and shift-click (#43)

Bug fixes:
- Health endpoint false-positive restarts (#41, thanks @wrenix and @ClemaX)
- Identity deletion failing (#42, thanks @freddij)
- Inline CID images, email list flicker, dark mode clipboard tint

Feature requests from @dlecourtaltimafr (#43, #44).
Contact pagination fix contributed by @capitanroy (#46).

Dependencies: Next.js 16.2.1, Tailwind 4.2.2, Zustand 5.0.12,
flatted CVE fix (GHSA-rf6f-7fwh-wjgh).
This commit is contained in:
Matthieu MALVACHE
2026-03-23 15:24:46 +01:00
committed by Matthieu MALVACHE
parent f6e188560f
commit 384b757815
46 changed files with 2993 additions and 1179 deletions
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## 1.4.0 (2026-03-23)
### Features
- **Folder management**: Create, rename, move, and delete mailbox folders from the sidebar
context menu, with drag-and-drop reparenting and inline editing (#44)
- **Mail multi-selection**: Select multiple emails with checkboxes or shift-click, then
batch move or delete from the toolbar. Includes a "Move to" popover with search and
keyboard navigation (#43)
### Fixes
- **Health endpoint**: Container restarts caused by false-positive memory alerts. The check
was using V8's current heap allocation as the max instead of the real heap limit (#41).
Thanks @wrenix and @ClemaX for reporting and diagnosing.
- **Identity deletion**: Fix "delete identity always failed" by adding the required
`urn:ietf:params:jmap:submission` capability to all Identity and EmailSubmission
operations (#42). Thanks @freddij for reporting.
- **Inline images**: CID-referenced images now render inline instead of showing as
attachments
- **Email list**: Eliminate flicker during loading and after-action refreshes
- **Copy to clipboard**: Visual feedback on copy, fix dark mode background tint
- **Console cleanup**: Remove production console statements
### Dependencies
- Next.js 16.2.0 -> 16.2.1
- Tailwind CSS 4.2.1 -> 4.2.2
- Zustand 5.0.11 -> 5.0.12
- typescript-eslint 8.56.1 -> 8.57.1
- Fix flatted prototype pollution (GHSA-rf6f-7fwh-wjgh)
## 1.3.3 (2026-03-20)
### Fixes
+3
View File
@@ -32,6 +32,9 @@ Stalwart is a mail server written in Rust with native JMAP support, not IMAP/SMT
- Responsive (desktop sidebar + mobile bottom tab bar + mobile action bar)
- Keyboard shortcuts
- Drag-and-drop email organization
- Multi-select emails (checkboxes, shift-click) with batch move/delete toolbar
- Folder management (create, rename, move, delete) from sidebar context menu
- Drag-and-drop folder reparenting
- Right-click context menus (long-press on touch devices)
- Extra-compact, compact, regular, and comfortable density options
- Resizable sidebar (drag, touch, keyboard)
+3 -1
View File
@@ -44,7 +44,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Full-text search
- [x] Advanced search with JMAP filter panel, search chips, and cross-mailbox queries
- [x] Attachment upload and download
- [x] Batch operations (multi-select)
- [x] Batch operations (multi-select with shift-click, batch move/delete toolbar)
- [x] Quick reply form
- [x] Email threading (Gmail-style inline expansion)
@@ -65,6 +65,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Drag-and-drop email organization
- [x] Right-click context menus
- [x] Hierarchical mailbox display
- [x] Folder management (create, rename, move, delete via context menu, drag-and-drop reparenting)
- [x] Email list with avatars and visual hierarchy
- [x] Expandable email headers
- [x] External content warning banner
@@ -246,6 +247,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [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] 704 tests total across 28 test suites
- [x] XSS attack vector testing
- [x] Playwright E2E framework setup
+1 -1
View File
@@ -1 +1 @@
1.3.3
1.4.0
+1 -1
View File
@@ -61,7 +61,7 @@ function OAuthCallbackInner() {
.catch(() => {
setError("token_exchange_failed");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- OAuth callback must run exactly once on mount
if (error) {
return (
+5 -10
View File
@@ -129,8 +129,7 @@ export default function ContactsPage() {
}
toast.success(t("toast.deleted"));
setView("list");
} catch (error) {
console.error('Failed to delete contact:', error);
} catch {
toast.error(t("toast.error_delete"));
}
};
@@ -206,8 +205,7 @@ export default function ContactsPage() {
toast.success(t("toast.deleted"));
setSelectedGroupId(null);
setView("list");
} catch (error) {
console.error('Failed to delete group:', error);
} catch {
toast.error(t("toast.error_delete"));
}
};
@@ -241,8 +239,7 @@ export default function ContactsPage() {
[memberId]
);
toast.success(t("toast.updated"));
} catch (error) {
console.error('Failed to remove group member:', error);
} catch {
toast.error(t("toast.error_update"));
}
};
@@ -265,8 +262,7 @@ export default function ContactsPage() {
);
toast.success(t("bulk.deleted", { count: selectedContactIds.size }));
setView("list");
} catch (error) {
console.error('Failed to bulk delete contacts:', error);
} catch {
toast.error(t("toast.error_delete"));
}
};
@@ -298,8 +294,7 @@ export default function ContactsPage() {
);
toast.success(t("bulk.added_to_group"));
setView("list");
} catch (error) {
console.error('Failed to add contacts to group:', error);
} catch {
toast.error(t("toast.error_update"));
}
};
-1
View File
@@ -21,7 +21,6 @@ export default function LocaleError({
const router = useRouter();
useEffect(() => {
console.error("Route error:", error);
}, [error]);
return (
+2 -2
View File
@@ -79,7 +79,7 @@ export default function LoginPage() {
const usernames = JSON.parse(saved);
setSavedUsernames(usernames);
} catch {
console.error("Failed to parse saved usernames");
return;
}
}
}, [serverUrl]);
@@ -193,7 +193,7 @@ export default function LoginPage() {
try {
usernames = JSON.parse(saved);
} catch {
console.error("Failed to parse saved usernames");
usernames = [];
}
}
+31 -34
View File
@@ -83,6 +83,7 @@ export default function Home() {
setLoadingEmail,
setPushConnected,
handleStateChange,
refreshCurrentMailbox,
clearNewEmailNotification,
markAsSpam,
undoSpam,
@@ -186,7 +187,7 @@ export default function Home() {
onDeselectAll: () => {
clearSelection();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- handlers are recreated each render; only rememoize on data/layout changes
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]);
// Initialize keyboard shortcuts
@@ -286,8 +287,8 @@ export default function Home() {
// Push notifications are optional - don't break the app if they fail
debug.log('[Push] Failed to setup push notifications:', error);
}
} catch (error) {
console.error('Error loading email data:', error);
} catch {
return;
}
};
loadData();
@@ -344,7 +345,7 @@ export default function Home() {
markAsReadTimeoutRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- mark-as-read triggers only on email selection, not on config changes
}, [selectedEmail?.id]);
// Handle new email notifications - play sound
@@ -392,10 +393,10 @@ export default function Home() {
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName);
setShowComposer(false);
// Refresh the current mailbox to update the UI
await fetchEmails(client, selectedMailbox);
} catch (error) {
console.error("Failed to send email:", error);
// Silent refresh — no loading indicator flash
await refreshCurrentMailbox(client);
} catch {
return;
}
};
@@ -404,8 +405,8 @@ export default function Home() {
try {
await client.deleteEmail(draftId);
} catch (error) {
console.error("Failed to discard draft:", error);
} catch {
return;
}
};
@@ -437,8 +438,8 @@ export default function Home() {
try {
await deleteEmail(client, selectedEmail.id);
dismissViewer();
} catch (error) {
console.error("Failed to delete email:", error);
} catch {
return;
}
};
@@ -451,8 +452,8 @@ export default function Home() {
try {
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
dismissViewer();
} catch (error) {
console.error("Failed to archive email:", error);
} catch {
return;
}
}
};
@@ -462,8 +463,8 @@ export default function Home() {
try {
await toggleStar(client, selectedEmail.id);
} catch (error) {
console.error("Failed to toggle star:", error);
} catch {
return;
}
};
@@ -483,16 +484,14 @@ export default function Home() {
try {
await undoSpam(client, emailId);
toastInstance.success(t('notifications.email_moved'));
} catch (_error) {
console.error("Failed to undo spam:", _error);
} catch {
toastInstance.error(t('email_viewer.spam.error'));
}
},
},
duration: 5000,
});
} catch (_error) {
console.error("Failed to mark as spam:", _error);
} catch {
const toastInstance = (await import('sonner')).toast;
toastInstance.error(t('email_viewer.spam.error'));
}
@@ -509,8 +508,7 @@ export default function Home() {
// Deselect email after moving it out of junk
selectEmail(null);
} catch (_error) {
console.error("Failed to restore email:", _error);
} catch {
const toastInstance = (await import('sonner')).toast;
toastInstance.error(t('email_viewer.spam.error_not_spam'));
}
@@ -544,10 +542,10 @@ export default function Home() {
// Update local state
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
// Refresh emails list to show color in list
await fetchEmails(client, selectedMailbox);
} catch (error) {
console.error("Failed to set color tag:", error);
// Silent refresh to show color in list
await refreshCurrentMailbox(client);
} catch {
return;
}
};
@@ -609,8 +607,8 @@ export default function Home() {
try {
await client.downloadBlob(blobId, name, type);
} catch (error) {
console.error("Failed to download attachment:", error);
} catch {
return;
}
};
@@ -638,8 +636,8 @@ export default function Home() {
primaryIdentity?.name || undefined
);
// Refresh emails to show the sent reply
await fetchEmails(client, selectedMailbox);
// Silent refresh to show the sent reply
await refreshCurrentMailbox(client);
};
// Show loading state while checking auth
@@ -681,8 +679,8 @@ export default function Home() {
selectEmail(fullEmail);
if (isTablet) setTabletListVisible(false);
}
} catch (error) {
console.error('Failed to fetch email content:', error);
} catch {
return;
} finally {
setLoadingEmail(false);
}
@@ -711,8 +709,7 @@ export default function Home() {
// Fetch complete thread emails
const emails = await client.getThreadEmails(thread.threadId);
setConversationEmails(emails);
} catch (error) {
console.error('Failed to fetch thread emails:', error);
} catch {
// Fall back to thread.emails
setConversationEmails(thread.emails);
} finally {
+9 -23
View File
@@ -1,10 +1,10 @@
import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { getHeapStatistics } from 'v8';
import { logger } from '@/lib/logger';
// Health check thresholds
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
const MEMORY_WARNING_THRESHOLD = 0.85;
const MEMORY_CRITICAL_THRESHOLD = 0.95;
interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
@@ -14,6 +14,7 @@ interface HealthStatus {
memory?: {
heapUsed: number;
heapTotal: number;
heapLimit: number;
rss: number;
external: number;
heapUsagePercent: number;
@@ -24,18 +25,6 @@ interface HealthStatus {
reason?: string;
}
/**
* Health check endpoint for container orchestration
*
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
* HEAD /api/health - Lightweight health check (status code only)
*
* Health status based on Node.js heap usage:
* - Healthy (200): < 85% heap usage
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
* - Unhealthy (503): > 95% heap usage
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const detailed = searchParams.get('detailed') === 'true';
@@ -43,9 +32,9 @@ export async function GET(request: NextRequest) {
try {
const timestamp = new Date().toISOString();
const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
const heapLimit = getHeapStatistics().heap_size_limit;
const heapUsagePercent = (memUsage.heapUsed / heapLimit) * 100;
// Determine health status based on memory usage
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
const warnings: string[] = [];
let httpStatus = 200;
@@ -58,7 +47,6 @@ export async function GET(request: NextRequest) {
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`);
}
// Build response
const response: HealthStatus = {
status,
timestamp,
@@ -68,13 +56,13 @@ export async function GET(request: NextRequest) {
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
}
// Add detailed information if requested
if (detailed) {
response.uptime = process.uptime();
response.version = process.env.npm_package_version || '0.1.0';
response.memory = {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
heapLimit,
rss: memUsage.rss,
external: memUsage.external,
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
@@ -111,13 +99,11 @@ export async function GET(request: NextRequest) {
}
}
/**
* HEAD method for ultra-lightweight health checks
*/
export async function HEAD() {
try {
const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
const heapLimit = getHeapStatistics().heap_size_limit;
const heapUsagePercent = (memUsage.heapUsed / heapLimit) * 100;
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
return new Response(null, { status: 503 });
-1
View File
@@ -23,7 +23,6 @@ export default function GlobalError({
reset: () => void;
}) {
useEffect(() => {
console.error("Global error:", error);
}, [error]);
return (
@@ -59,8 +59,7 @@ export function ContactImportDialog({
if (!dupes.has(idx)) initialSelected.add(idx);
});
setSelected(initialSelected);
} catch (error) {
console.error('Failed to parse vCard:', error);
} catch {
setError(t("import.parse_error"));
}
}, [existingContacts, t]);
@@ -91,8 +90,7 @@ export function ContactImportDialog({
try {
const count = await onImport(toImport);
setResult(count);
} catch (error) {
console.error('Failed to import contacts:', error);
} catch {
setError(t("import.failed"));
} finally {
setIsImporting(false);
@@ -87,7 +87,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
if (attachment) {
parseEvent();
}
}, [email.id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [email.id]); // eslint-disable-line react-hooks/exhaustive-deps -- only re-parse when viewing a different email
useEffect(() => {
if (calendars.length > 0 && !selectedCalendarId) {
+1 -2
View File
@@ -381,8 +381,7 @@ export function EmailComposer({
setTimeout(() => setSaveStatus('idle'), 2000);
return savedDraftId;
} catch (error) {
console.error('Failed to save draft:', error);
} catch {
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
return null;
+89 -26
View File
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
import { Inbox, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -18,6 +18,8 @@ import { useTranslations } from "next-intl";
import { useVirtualizer } from "@tanstack/react-virtual";
import { SearchChips } from "@/components/search/search-chips";
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
import { SelectionDropdown } from "./selection-dropdown";
import { MoveToPopover } from "./move-to-popover";
interface EmailListProps {
emails: Email[];
@@ -62,8 +64,12 @@ export function EmailList({
const { client } = useAuthStore();
const {
selectedEmailIds,
toggleEmailSelection,
selectAllEmails,
clearSelection,
selectRange,
lastSelectedIndex,
selectByFilter,
batchMarkAsRead,
batchDelete,
batchMoveToMailbox,
@@ -86,6 +92,16 @@ export function EmailList({
advancedSearch,
} = useEmailStore();
const [showRefreshOverlay, setShowRefreshOverlay] = useState(false);
useEffect(() => {
if (!isLoading || emails.length === 0) {
setShowRefreshOverlay(false);
return;
}
const timer = setTimeout(() => setShowRefreshOverlay(true), 300);
return () => clearTimeout(timer);
}, [isLoading, emails.length]);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails);
return sortThreadGroups(groups);
@@ -133,7 +149,7 @@ export function EmailList({
);
const hasSelection = selectedEmailIds.size > 0;
const allSelected = emails.length > 0 && emails.every(e => selectedEmailIds.has(e.id));
const allSelected = threadGroups.length > 0 && threadGroups.every(g => selectedEmailIds.has(g.latestEmail.id));
const handleBatchMarkAsRead = async (read: boolean) => {
if (!client || isProcessing) return;
@@ -164,6 +180,25 @@ export function EmailList({
}
};
const handleBatchMove = async (mailboxId: string) => {
if (!client || isProcessing) return;
setIsProcessing(true);
try {
await batchMoveToMailbox(client, mailboxId);
const { toast } = await import('sonner');
const targetMailbox = mailboxes.find(m => m.id === mailboxId);
toast.success(t('batch_actions.move_success', {
count: selectedEmailIds.size,
folder: targetMailbox?.name || mailboxId,
}));
} catch {
const { toast } = await import('sonner');
toast.error(t('batch_actions.move_error'));
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
};
const handleLoadMore = useCallback(() => {
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client);
@@ -181,6 +216,15 @@ export function EmailList({
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
const handleCheckboxClick = useCallback((e: React.MouseEvent, emailId: string, groupIndex: number) => {
e.stopPropagation();
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(lastSelectedIndex, groupIndex, threadGroups);
} else {
toggleEmailSelection(emailId, groupIndex);
}
}, [lastSelectedIndex, selectRange, toggleEmailSelection, threadGroups]);
// Range-based load more: trigger when last visible item is near the end
const virtualItems = virtualizer.getVirtualItems();
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
@@ -202,15 +246,36 @@ export function EmailList({
if (index >= 0) {
virtualizer.scrollToIndex(index, { align: 'auto' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- scroll only on selection change, not on list re-renders
}, [selectedEmailId]);
// Re-measure all items when density or preview settings change
useEffect(() => {
virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- virtualizer ref is stable, only re-measure on setting changes
}, [listDensity, showPreview]);
useEffect(() => {
const container = parentRef.current;
if (!container) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
const activeEl = document.activeElement;
const isInInput = activeEl instanceof HTMLInputElement ||
activeEl instanceof HTMLTextAreaElement ||
(activeEl instanceof HTMLElement && activeEl.isContentEditable);
if (isInInput) return;
e.preventDefault();
selectAllEmails(threadGroups);
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [selectAllEmails, threadGroups]);
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Batch Actions Toolbar */}
@@ -222,8 +287,8 @@ export function EmailList({
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
{selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected
<span className="text-sm font-medium text-foreground" aria-live="polite">
{t('batch_actions.selected_count', { count: selectedEmailIds.size })}
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
@@ -255,6 +320,12 @@ export function EmailList({
<Mail className="w-4 h-4" />
)}
</Button>
<MoveToPopover
mailboxes={mailboxes}
currentMailboxId={selectedMailbox}
onMove={handleBatchMove}
disabled={isProcessing}
/>
<Button
variant="ghost"
size="sm"
@@ -278,7 +349,7 @@ export function EmailList({
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
Cancel
{t('batch_actions.clear_selection')}
</Button>
</div>
</div>
@@ -303,22 +374,12 @@ export function EmailList({
{/* List Header */}
<div className="px-4 py-3 border-b bg-muted/50 border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => allSelected ? clearSelection() : selectAllEmails()}
className={cn(
"p-1 rounded transition-all duration-200",
"hover:bg-muted hover:scale-110",
"active:scale-95",
allSelected && "text-primary"
)}
title={allSelected ? "Deselect all" : "Select all"}
>
{allSelected ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4" />
)}
</button>
<SelectionDropdown
hasSelection={hasSelection}
allSelected={allSelected}
onSelectByFilter={(filter, groups) => selectByFilter(filter, groups)}
threadGroups={threadGroups}
/>
<h2 className="text-sm font-medium text-foreground">
{isLoading ? t('loading') : threadGroups.length > 0
? (totalEmails !== undefined && totalEmails > threadGroups.length
@@ -332,9 +393,9 @@ export function EmailList({
</div>
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" tabIndex={0}>
{/* Loading overlay */}
{isLoading && emails.length > 0 && (
{showRefreshOverlay && (
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border">
<Loader2 className="w-4 h-4 animate-spin" />
@@ -354,7 +415,7 @@ export function EmailList({
) : (
<>
<div
className={cn("transition-opacity duration-200", isLoading && "opacity-50")}
className="w-full"
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
@@ -386,6 +447,8 @@ export function EmailList({
onEmailSelect={(email) => onEmailSelect?.(email)}
onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation}
isChecked={selectedEmailIds.has(thread.latestEmail.id)}
onCheckboxClick={(e) => handleCheckboxClick(e, thread.latestEmail.id, virtualItem.index)}
/>
</div>
);
+66 -17
View File
@@ -186,7 +186,6 @@ export function EmailViewer({
className,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
@@ -209,7 +208,7 @@ export function EmailViewer({
// Tablet list visibility
const { isMobile, isTablet } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities } = useAuthStore();
const { identities, client } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [allowExternalContent, setAllowExternalContent] = useState(false);
@@ -229,6 +228,43 @@ export function EmailViewer({
}
);
const [cidUrls, setCidUrls] = useState<Map<string, string>>(new Map());
useEffect(() => {
if (!email?.attachments || !client) {
setCidUrls(new Map());
return;
}
const inlineAtts = email.attachments.filter(a => a.cid && a.blobId);
if (inlineAtts.length === 0) {
setCidUrls(new Map());
return;
}
let cancelled = false;
const objectUrls: string[] = [];
Promise.all(
inlineAtts.map(async (att) => {
try {
const objectUrl = await client.fetchBlobAsObjectUrl(att.blobId, att.name, att.type);
objectUrls.push(objectUrl);
return [att.cid!, objectUrl] as const;
} catch {
return null;
}
})
).then((results) => {
if (cancelled) return;
const map = new Map<string, string>();
for (const r of results) {
if (r) map.set(r[0], r[1]);
}
setCidUrls(map);
});
return () => {
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [email?.id, email?.attachments, client]);
useEffect(() => {
// Mark as read when email is viewed
if (email && !email.keywords?.$seen && onMarkAsRead) {
@@ -389,16 +425,17 @@ export function EmailViewer({
return source;
};
const [sourceCopied, setSourceCopied] = useState(false);
const copySourceToClipboard = async () => {
if (!email) return;
try {
const source = generateEmailSource(email);
await navigator.clipboard.writeText(source);
// Could add a toast notification here
console.log(tNotifications('source_copied'));
} catch (err) {
console.error('Failed to copy source:', err);
setSourceCopied(true);
setTimeout(() => setSourceCopied(false), 2000);
} catch {
return;
}
};
@@ -512,6 +549,14 @@ export function EmailViewer({
setHasBlockedContent(true);
}
// Replace cid: references with pre-fetched object URLs
if (cidUrls.size > 0) {
cleanHtml = cleanHtml.replace(/src="cid:([^"]+)"/gi, (match, cid) => {
const url = cidUrls.get(cid);
return url ? `src="${url}"` : match;
});
}
return {
html: cleanHtml,
isHtml: true,
@@ -561,7 +606,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme, cidUrls]);
// Detect List-Unsubscribe header for newsletter banners
const listHeaders = useMemo(() => {
@@ -1179,7 +1224,7 @@ export function EmailViewer({
</div>
)}
{/* AI Analysis (X-Spam-LLM) - Full width card */}
{/* Spam analysis (X-Spam-LLM header) */}
{email.spamLLM && (
<div className={cn(
"mt-3 px-4 py-3 rounded-lg",
@@ -1212,7 +1257,7 @@ export function EmailViewer({
? "text-red-700 dark:text-red-400"
: "text-amber-700 dark:text-amber-400"
)}>
AI Analysis: {email.spamLLM.verdict}
Spam Analysis: {email.spamLLM.verdict}
</span>
</div>
<p className="text-xs text-gray-700 dark:text-gray-300 leading-relaxed">
@@ -1457,20 +1502,22 @@ export function EmailViewer({
<div className="max-w-4xl mx-auto p-6">
{/* Inline Attachments */}
{email.attachments && email.attachments.length > 0 && (
{/* Attachments (excluding inline/CID images already rendered in the body) */}
{email.attachments && email.attachments.filter(a => !a.cid).length > 0 && (
<div className="mb-4">
{/* Image attachments as thumbnails */}
{email.attachments.filter(a =>
!a.cid && (
a.type?.startsWith('image/') ||
['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '')
['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || ''))
).length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-2">
{email.attachments
.filter(a =>
!a.cid && (
a.type?.startsWith('image/') ||
['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '')
['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || ''))
)
.map((attachment, i) => (
<div
@@ -1502,6 +1549,7 @@ export function EmailViewer({
{/* Non-image attachments in a compact list */}
{email.attachments.filter(a =>
!a.cid &&
!a.type?.startsWith('image/') &&
!['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '')
).length > 0 && (
@@ -1509,6 +1557,7 @@ export function EmailViewer({
<Paperclip className="w-4 h-4 text-muted-foreground" />
{email.attachments
.filter(a =>
!a.cid &&
!a.type?.startsWith('image/') &&
!['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(a.name?.split('.').pop()?.toLowerCase() || '')
)
@@ -1639,8 +1688,8 @@ export function EmailViewer({
await onQuickReply(quickReplyText);
setQuickReplyText("");
setIsQuickReplyFocused(false);
} catch (error) {
console.error("Failed to send quick reply:", error);
} catch {
return;
} finally {
setIsSendingQuickReply(false);
}
@@ -1706,8 +1755,8 @@ export function EmailViewer({
onClick={copySourceToClipboard}
className="flex items-center gap-1.5"
>
<Copy className="w-4 h-4" />
{t('copy_source')}
{sourceCopied ? <Check className="w-4 h-4 text-green-600 dark:text-green-400" /> : <Copy className="w-4 h-4" />}
{sourceCopied ? tCommon('copied') : t('copy_source')}
</Button>
<Button
variant="ghost"
+260
View File
@@ -0,0 +1,260 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { createPortal } from "react-dom";
import { FolderInput, Search, Inbox, Send, FileText, Archive, Trash2, AlertTriangle, Folder } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { Mailbox } from "@/lib/jmap/types";
interface MoveToPopoverProps {
mailboxes: Mailbox[];
currentMailboxId: string;
onMove: (mailboxId: string) => void;
disabled?: boolean;
}
const roleIcons: Record<string, React.ComponentType<{ className?: string }>> = {
inbox: Inbox,
sent: Send,
drafts: FileText,
archive: Archive,
trash: Trash2,
junk: AlertTriangle,
};
function getMailboxIcon(mailbox: Mailbox) {
if (mailbox.role && roleIcons[mailbox.role]) {
return roleIcons[mailbox.role];
}
return Folder;
}
function getMailboxDepth(mailbox: Mailbox, allMailboxes: Mailbox[]): number {
let depth = 0;
let current = mailbox;
while (current.parentId) {
depth++;
const parent = allMailboxes.find(m => m.id === current.parentId);
if (!parent) break;
current = parent;
}
return depth;
}
export function MoveToPopover({ mailboxes, currentMailboxId, onMove, disabled }: MoveToPopoverProps) {
const t = useTranslations('email_list');
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [focusedIndex, setFocusedIndex] = useState(0);
const availableMailboxes = useMemo(() => {
return mailboxes.filter(m => m.id !== currentMailboxId);
}, [mailboxes, currentMailboxId]);
const filteredMailboxes = useMemo(() => {
const systemRoles = ['inbox', 'sent', 'drafts', 'archive', 'trash', 'junk'];
const query = search.toLowerCase();
const filtered = availableMailboxes.filter(m =>
m.name.toLowerCase().includes(query)
);
const system = filtered.filter(m => m.role && systemRoles.includes(m.role));
const custom = filtered.filter(m => !m.role || !systemRoles.includes(m.role));
return { system, custom };
}, [availableMailboxes, search]);
const allFiltered = [...filteredMailboxes.system, ...filteredMailboxes.custom];
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => searchInputRef.current?.focus(), 50);
const handleClickOutside = (e: MouseEvent) => {
if (
popoverRef.current && !popoverRef.current.contains(e.target as Node) &&
buttonRef.current && !buttonRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
useEffect(() => {
setFocusedIndex(0);
}, [search]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setFocusedIndex(prev => Math.min(prev + 1, allFiltered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setFocusedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' && allFiltered[focusedIndex]) {
e.preventDefault();
handleMove(allFiltered[focusedIndex].id);
}
};
const handleMove = (mailboxId: string) => {
onMove(mailboxId);
setIsOpen(false);
setSearch("");
};
const getPopoverPosition = () => {
if (!buttonRef.current) return { top: 0, left: 0 };
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const popoverHeight = 360;
if (spaceBelow < popoverHeight && rect.top > popoverHeight) {
return { bottom: window.innerHeight - rect.top + 4, left: rect.left };
}
return { top: rect.bottom + 4, left: rect.left };
};
const highlightMatch = (text: string) => {
if (!search) return text;
const idx = text.toLowerCase().indexOf(search.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<strong className="text-foreground">{text.slice(idx, idx + search.length)}</strong>
{text.slice(idx + search.length)}
</>
);
};
return (
<>
<button
ref={buttonRef}
onClick={() => { setIsOpen(!isOpen); setSearch(""); }}
disabled={disabled}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md",
"transition-colors duration-200",
"hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed",
"text-foreground"
)}
title={t('batch_actions.move_to')}
>
<FolderInput className="w-4 h-4" />
<span className="hidden sm:inline">{t('batch_actions.move_to')}</span>
</button>
{isOpen && createPortal(
<div
ref={popoverRef}
className={cn(
"fixed z-50 w-[260px] bg-background rounded-lg shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100"
)}
style={getPopoverPosition()}
onKeyDown={handleKeyDown}
>
<div className="p-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
ref={searchInputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('move_to.search_placeholder')}
className={cn(
"w-full pl-8 pr-3 py-1.5 text-sm rounded-md",
"bg-muted/50 border border-border",
"focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary",
"placeholder:text-muted-foreground"
)}
/>
</div>
</div>
<div className="max-h-[280px] overflow-y-auto py-1">
{allFiltered.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{availableMailboxes.length === 0
? t('move_to.no_other_folders')
: t('move_to.no_results')}
</div>
) : (
<>
{filteredMailboxes.system.map((mailbox, index) => {
const Icon = getMailboxIcon(mailbox);
return (
<button
key={mailbox.id}
className={cn(
"w-full px-3 py-2 text-sm text-left flex items-center gap-2",
"transition-colors duration-100 cursor-pointer",
"hover:bg-muted focus:outline-none focus:bg-muted",
focusedIndex === index && "bg-muted",
mailbox.role === 'trash' && "text-red-600 dark:text-red-400",
mailbox.role === 'junk' && "text-amber-700 dark:text-amber-400"
)}
onClick={() => handleMove(mailbox.id)}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{highlightMatch(mailbox.name)}</span>
</button>
);
})}
{filteredMailboxes.custom.length > 0 && (
<>
{filteredMailboxes.system.length > 0 && (
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('move_to.custom_folders')}
</div>
)}
{filteredMailboxes.custom.map((mailbox, index) => {
const Icon = getMailboxIcon(mailbox);
const depth = getMailboxDepth(mailbox, mailboxes);
const flatIndex = filteredMailboxes.system.length + index;
return (
<button
key={mailbox.id}
className={cn(
"w-full py-2 text-sm text-left flex items-center gap-2",
"transition-colors duration-100 cursor-pointer",
"hover:bg-muted focus:outline-none focus:bg-muted",
focusedIndex === flatIndex && "bg-muted"
)}
style={{ paddingLeft: `${12 + depth * 12}px`, paddingRight: '12px' }}
onClick={() => handleMove(mailbox.id)}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{highlightMatch(mailbox.name)}</span>
</button>
);
})}
</>
)}
</>
)}
</div>
</div>,
document.body
)}
</>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { ThreadGroup } from "@/lib/jmap/types";
type SelectionFilter = 'all' | 'none' | 'read' | 'unread' | 'starred' | 'unstarred';
interface SelectionDropdownProps {
hasSelection: boolean;
allSelected: boolean;
onSelectByFilter: (filter: SelectionFilter, threadGroups: ThreadGroup[]) => void;
threadGroups: ThreadGroup[];
}
export function SelectionDropdown({ hasSelection, allSelected, onSelectByFilter, threadGroups }: SelectionDropdownProps) {
const t = useTranslations('email_list');
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
menuRef.current && !menuRef.current.contains(e.target as Node) &&
buttonRef.current && !buttonRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const handleSelect = (filter: SelectionFilter) => {
onSelectByFilter(filter, threadGroups);
setIsOpen(false);
};
const filters: { key: SelectionFilter; label: string }[] = [
{ key: 'all', label: t('select_criteria.all') },
{ key: 'none', label: t('select_criteria.none') },
{ key: 'read', label: t('select_criteria.read') },
{ key: 'unread', label: t('select_criteria.unread') },
{ key: 'starred', label: t('select_criteria.starred') },
{ key: 'unstarred', label: t('select_criteria.unstarred') },
];
const getMenuPosition = () => {
if (!buttonRef.current) return { top: 0, left: 0 };
const rect = buttonRef.current.getBoundingClientRect();
return { top: rect.bottom + 4, left: rect.left };
};
return (
<div className="relative">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className={cn(
"flex items-center gap-1 p-1 rounded transition-all duration-200",
"hover:bg-muted hover:scale-105",
"active:scale-95",
(hasSelection || allSelected) && "text-primary"
)}
aria-label={t('select_criteria.label')}
aria-haspopup="true"
aria-expanded={isOpen}
>
<div className={cn(
"w-4 h-4 rounded-sm border-2 transition-colors",
allSelected
? "bg-primary border-primary"
: hasSelection
? "bg-primary/50 border-primary"
: "border-muted-foreground"
)} />
<ChevronDown className="w-3 h-3 text-muted-foreground" />
</button>
{isOpen && createPortal(
<div
ref={menuRef}
className={cn(
"fixed z-50 min-w-[160px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100"
)}
style={getMenuPosition()}
role="menu"
>
<div className="py-1">
{filters.map(({ key, label }) => (
<button
key={key}
role="menuitem"
className={cn(
"w-full px-3 py-2 text-sm text-left",
"transition-colors duration-100",
"hover:bg-muted cursor-pointer",
"focus:outline-none focus:bg-muted"
)}
onClick={() => handleSelect(key)}
>
{label}
</button>
))}
</div>
</div>,
document.body
)}
</div>
);
}
+51 -4
View File
@@ -30,6 +30,7 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
interface ThreadConversationViewProps {
thread: ThreadGroup;
@@ -225,6 +226,44 @@ function EmailCard({
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const { client } = useAuthStore();
const [cidUrls, setCidUrls] = useState<Map<string, string>>(new Map());
useEffect(() => {
if (!email?.attachments || !client) {
setCidUrls(new Map());
return;
}
const inlineAtts = email.attachments.filter(a => a.cid && a.blobId);
if (inlineAtts.length === 0) {
setCidUrls(new Map());
return;
}
let cancelled = false;
const objectUrls: string[] = [];
Promise.all(
inlineAtts.map(async (att) => {
try {
const objectUrl = await client.fetchBlobAsObjectUrl(att.blobId, att.name, att.type);
objectUrls.push(objectUrl);
return [att.cid!, objectUrl] as const;
} catch {
return null;
}
})
).then((results) => {
if (cancelled) return;
const map = new Map<string, string>();
for (const r of results) {
if (r) map.set(r[0], r[1]);
}
setCidUrls(map);
});
return () => {
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [email?.id, email?.attachments, client]);
// Mark as read when email is expanded
useEffect(() => {
@@ -333,6 +372,14 @@ function EmailCard({
finalHtml = collapseBlockedImageContainers(sanitized);
}
// Replace cid: references with pre-fetched object URLs
if (cidUrls.size > 0) {
finalHtml = finalHtml.replace(/src="cid:([^"]+)"/gi, (match, cid) => {
const url = cidUrls.get(cid);
return url ? `src="${url}"` : match;
});
}
return { html: finalHtml, isHtml: true, useIframe: needsIframeRendering(htmlContent) };
}
@@ -355,7 +402,7 @@ function EmailCard({
}
return { html: "", isHtml: false };
}, [email, allowExternal, resolvedTheme]);
}, [email, allowExternal, resolvedTheme, cidUrls]);
return (
<div className={cn(
@@ -464,11 +511,11 @@ function EmailCard({
)}
</div>
{/* Attachments */}
{email.attachments && email.attachments.length > 0 && (
{/* Attachments (excluding inline/CID images rendered in the body) */}
{email.attachments && email.attachments.filter(a => !a.cid).length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{email.attachments.map((attachment, idx) => {
{email.attachments.filter(a => !a.cid).map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
return (
<button
+47 -5
View File
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare } from "lucide-react";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square } from "lucide-react";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { getThreadColorTag } from "@/lib/thread-utils";
@@ -22,6 +22,8 @@ interface ThreadListItemProps {
onEmailSelect: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void;
isChecked: boolean;
onCheckboxClick: (e: React.MouseEvent) => void;
}
const colorTags = {
@@ -41,10 +43,12 @@ interface SingleEmailItemProps {
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
isChecked: boolean;
onCheckboxClick: (e: React.MouseEvent) => void;
}
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) {
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, isChecked, onCheckboxClick }, ref) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0];
@@ -66,7 +70,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !colorTag && "bg-accent/30"
isUnread && !colorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40"
)}
onClick={onClick}
onContextMenu={handleContextMenu}
@@ -76,7 +81,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
<div className="w-6 flex-shrink-0" />
<button
onClick={onCheckboxClick}
className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
@@ -158,6 +177,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onEmailSelect,
onContextMenu,
onOpenConversation,
isChecked,
onCheckboxClick,
}, ref) {
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview);
@@ -180,6 +201,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
isChecked={isChecked}
onCheckboxClick={onCheckboxClick}
/>
);
}
@@ -221,7 +244,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50"
isExpanded && "border-b border-border/50",
isChecked && "ring-2 ring-primary/20 bg-accent/40"
)}
onClick={handleHeaderClick}
onContextMenu={handleContextMenu}
@@ -231,6 +255,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{!isMobile && (
<button
onClick={onCheckboxClick}
className={cn(
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{!isMobile && (
<button
data-expand-toggle
+1 -2
View File
@@ -62,8 +62,7 @@ export function UnsubscribeBanner({
setProcessing(false);
setTimeout(onDismiss, 3000);
}
} catch (err) {
console.error('Unsubscribe error:', err);
} catch {
setError(true);
setProcessing(false);
}
+570 -31
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { Button } from "@/components/ui/button";
@@ -27,8 +28,12 @@ import {
Settings,
X,
Tag,
Plus,
FolderPlus,
Edit3,
FolderInput,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { cn, buildMailboxTree, flattenMailboxTree, 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";
@@ -40,6 +45,7 @@ import { useVacationStore } from "@/stores/vacation-store";
import { useResizeHandle } from "@/hooks/use-resize-handle";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
interface SidebarProps {
mailboxes: Mailbox[];
@@ -84,6 +90,112 @@ const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean,
return Inbox;
};
function InlineInput({
defaultValue = "",
placeholder,
hintText,
borderColor = "border-green-500",
onSubmit,
onCancel,
depth = 0,
}: {
defaultValue?: string;
placeholder?: string;
hintText: string;
borderColor?: string;
onSubmit: (value: string) => void;
onCancel: () => void;
depth?: number;
}) {
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
const cancelledRef = useRef(false);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
onSubmit(value);
} else if (e.key === 'Escape') {
e.preventDefault();
cancelledRef.current = true;
onCancel();
}
};
return (
<div style={{ paddingLeft: `${depth * 16 + 24}px` }} className="px-2 py-1">
<div className="flex items-center gap-1">
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => { if (!cancelledRef.current) onCancel(); }}
placeholder={placeholder}
maxLength={200}
className={cn(
"flex-1 bg-background text-foreground text-sm px-1.5 py-0.5 rounded border-2 outline-none",
borderColor
)}
/>
<button
onMouseDown={(e) => { e.preventDefault(); onSubmit(value); }}
className="text-green-500 hover:text-green-400 p-0.5"
aria-label="Confirm"
>
<span className="text-sm">&#10003;</span>
</button>
<button
onMouseDown={(e) => { e.preventDefault(); onCancel(); }}
className="text-red-500 hover:text-red-400 p-0.5"
aria-label="Cancel"
>
<span className="text-sm">&#10005;</span>
</button>
</div>
<div className="text-xs text-muted-foreground mt-0.5 ml-6">{hintText}</div>
</div>
);
}
function RenameInput({ defaultValue, onSubmit, onCancel }: {
defaultValue: string;
onSubmit: (value: string) => void;
onCancel: () => void;
}) {
const cancelledRef = useRef(false);
return (
<input
autoFocus
type="text"
defaultValue={defaultValue}
maxLength={200}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onSubmit((e.target as HTMLInputElement).value);
} else if (e.key === 'Escape') {
e.preventDefault();
cancelledRef.current = true;
onCancel();
}
}}
onBlur={(e) => {
if (!cancelledRef.current) onSubmit(e.target.value);
}}
onClick={(e) => e.stopPropagation()}
className="flex-1 bg-background text-foreground text-sm px-1.5 py-0.5 rounded border-2 border-primary outline-none min-w-0"
/>
);
}
function MailboxTreeItem({
node,
selectedMailbox,
@@ -92,6 +204,13 @@ function MailboxTreeItem({
onToggleExpand,
onMailboxContextMenu,
isCollapsed,
renamingMailboxId,
onRenameSubmit,
onRenameCancel,
onStartRename,
creatingSubfolder,
onCreateSubmit,
onCreateCancel,
}: {
node: MailboxNode;
selectedMailbox: string;
@@ -100,16 +219,26 @@ function MailboxTreeItem({
onToggleExpand: (id: string) => void;
onMailboxContextMenu?: (e: React.MouseEvent, mailbox: Mailbox) => void;
isCollapsed: boolean;
renamingMailboxId: string | null;
onRenameSubmit: (value: string) => void;
onRenameCancel: () => void;
onStartRename: (id: string) => void;
creatingSubfolder: { parentId: string } | null;
onCreateSubmit: (value: string) => void;
onCreateCancel: () => void;
}) {
const t = useTranslations('sidebar');
const tFolder = useTranslations('sidebar.folder_management');
const tNotifications = useTranslations('notifications');
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
const indentPixels = node.depth * 16;
const isVirtualNode = node.id.startsWith('shared-');
const isRenaming = renamingMailboxId === node.id;
const isCreatingChild = creatingSubfolder?.parentId === node.id;
const { isDragging: globalDragging } = useDragDropContext();
const { isDragging: globalDragging, startMailboxDrag, endDrag: globalEndDrag, dragType, draggedMailboxId } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
mailbox: node,
onSuccess: (count, mailboxName) => {
@@ -130,11 +259,44 @@ function MailboxTreeItem({
},
});
const canDrag = !isVirtualNode && !node.isShared && !node.id.startsWith('shared-') &&
!node.id.startsWith('temp-') && node.myRights?.mayRename;
const handleFolderDragStart = useCallback((e: React.DragEvent) => {
if (!canDrag) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("application/x-mailbox-id", node.id);
e.dataTransfer.setData("text/plain", node.name);
const preview = document.createElement("div");
preview.style.cssText = `
position: fixed; top: -9999px; left: 0; padding: 6px 12px;
background-color: var(--color-primary, #3b82f6); color: white;
border-radius: 6px; font-size: 13px; font-weight: 500; white-space: nowrap;
`;
preview.textContent = node.name;
document.body.appendChild(preview);
e.dataTransfer.setDragImage(preview, 0, 0);
requestAnimationFrame(() => preview.remove());
startMailboxDrag(node.id);
}, [canDrag, node.id, node.name, startMailboxDrag]);
const handleFolderDragEnd = useCallback(() => {
globalEndDrag();
}, [globalEndDrag]);
return (
<>
<div
{...(globalDragging ? dropHandlers : {})}
onContextMenu={(e) => onMailboxContextMenu?.(e, node)}
draggable={canDrag}
onDragStart={handleFolderDragStart}
onDragEnd={handleFolderDragEnd}
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
@@ -144,7 +306,8 @@ function MailboxTreeItem({
: "hover:bg-muted text-foreground",
node.depth === 0 && !isVirtualNode && "font-medium",
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50",
globalDragging && dragType === 'mailbox' && draggedMailboxId === node.id && "opacity-40"
)}
>
{hasChildren && (
@@ -169,8 +332,8 @@ function MailboxTreeItem({
)}
<button
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
disabled={isVirtualNode}
onClick={() => !isVirtualNode && !node.id.startsWith('temp-') && onMailboxSelect?.(node.id)}
disabled={isVirtualNode || node.id.startsWith('temp-')}
className={cn(
"flex-1 flex items-center text-left py-1 lg:py-1 max-lg:py-2 px-1 rounded",
"transition-colors duration-150",
@@ -190,8 +353,26 @@ function MailboxTreeItem({
)} />
{!isCollapsed && (
<>
<span className="flex-1 truncate">{node.name}</span>
{node.unreadEmails > 0 && (
{isRenaming ? (
<RenameInput
defaultValue={node.name}
onSubmit={onRenameSubmit}
onCancel={onRenameCancel}
/>
) : (
<span
className="flex-1 truncate"
onDoubleClick={(e) => {
if (!node.role && !node.isShared && !isVirtualNode) {
e.stopPropagation();
onStartRename(node.id);
}
}}
>
{node.name}
</span>
)}
{!isRenaming && node.unreadEmails > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 ml-2 font-medium",
selectedMailbox === node.id
@@ -218,10 +399,36 @@ function MailboxTreeItem({
onToggleExpand={onToggleExpand}
onMailboxContextMenu={onMailboxContextMenu}
isCollapsed={isCollapsed}
renamingMailboxId={renamingMailboxId}
onRenameSubmit={onRenameSubmit}
onRenameCancel={onRenameCancel}
onStartRename={onStartRename}
creatingSubfolder={creatingSubfolder}
onCreateSubmit={onCreateSubmit}
onCreateCancel={onCreateCancel}
/>
))}
{isCreatingChild && (
<InlineInput
placeholder={tFolder('folder_name_placeholder')}
hintText={tFolder('enter_to_create')}
onSubmit={onCreateSubmit}
onCancel={onCreateCancel}
depth={node.depth + 1}
/>
)}
</div>
)}
{!hasChildren && isCreatingChild && !isCollapsed && (
<InlineInput
placeholder={tFolder('folder_name_placeholder')}
hintText={tFolder('enter_to_create')}
onSubmit={onCreateSubmit}
onCancel={onCreateCancel}
depth={node.depth + 1}
/>
)}
</>
);
}
@@ -422,6 +629,85 @@ function StorageQuota({ quota, isCollapsed }: { quota: { used: number; total: nu
);
}
function MoveToSubmenu({
mailbox,
allMailboxes,
onMove,
}: {
mailbox: Mailbox;
allMailboxes: Mailbox[];
onMove: (targetId: string | null) => void;
onClose: () => void;
}) {
const tFolder = useTranslations('sidebar.folder_management');
const [showSubmenu, setShowSubmenu] = useState(false);
const isDescendant = (parentId: string, checkId: string): boolean => {
const children = allMailboxes.filter(mb => mb.parentId === parentId);
return children.some(c => c.id === checkId || isDescendant(c.id, checkId));
};
const tree = buildMailboxTree(allMailboxes);
const flatTree = flattenMailboxTree(tree);
const validTargets = flatTree.filter(mb => {
if (mb.id === mailbox.id) return false;
if (mb.isShared || mb.id.startsWith('shared-')) return false;
if (!mb.myRights?.mayCreateChild) return false;
if (isDescendant(mailbox.id, mb.id)) return false;
return true;
});
const canMoveToRoot = !!mailbox.parentId;
return (
<div
className="relative"
onMouseEnter={() => setShowSubmenu(true)}
onMouseLeave={() => setShowSubmenu(false)}
>
<div className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted transition-colors cursor-pointer">
<FolderInput className="w-4 h-4 mr-2" />
{tFolder("move_to")}
<ChevronRight className="w-3 h-3 ml-auto" />
</div>
{showSubmenu && (
<div className="absolute left-full top-0 bg-background border border-border rounded-md shadow-lg py-1 min-w-[180px] max-h-[300px] overflow-y-auto z-50">
{canMoveToRoot && (
<>
<button
onClick={() => onMove(null)}
className="flex items-center w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors font-medium"
>
<Folder className="w-3.5 h-3.5 mr-2 flex-shrink-0 text-muted-foreground" />
{tFolder("move_to_top_level")}
</button>
<div className="h-px bg-border mx-2 my-1" />
</>
)}
{validTargets.length === 0 && !canMoveToRoot ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{tFolder("no_available_targets")}
</div>
) : (
validTargets.map((target) => (
<button
key={target.id}
onClick={() => onMove(target.id)}
className="flex items-center w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
style={{ paddingLeft: `${12 + target.depth * 12}px` }}
>
<Folder className="w-3.5 h-3.5 mr-2 flex-shrink-0 text-muted-foreground" />
<span className="truncate">{target.name}</span>
</button>
))
)}
</div>
)}
</div>
);
}
export function Sidebar({
mailboxes = [],
selectedMailbox = "",
@@ -440,10 +726,18 @@ export function Sidebar({
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 contextMenuRef = useRef<HTMLDivElement>(null);
const [emptyFolderTarget, setEmptyFolderTarget] = useState<Mailbox | null>(null);
const [renamingMailboxId, setRenamingMailboxId] = useState<string | null>(null);
const [creatingSubfolder, setCreatingSubfolder] = useState<{ parentId: string } | null>(null);
const [creatingTopLevel, setCreatingTopLevel] = useState(false);
const [deleteFolderTarget, setDeleteFolderTarget] = useState<Mailbox | null>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
const t = useTranslations('sidebar');
const tFolder = useTranslations('sidebar.folder_management');
const { dragType, endDrag: globalEndDrag } = useDragDropContext();
const { client } = useAuthStore();
const { emptyFolder } = useEmailStore();
const { emptyFolder, createMailbox, renameMailbox, moveMailbox, deleteMailbox } = useEmailStore();
const { sidebarWidth, updateSetting } = useSettingsStore();
const handleSidebarResize = useCallback((width: number) => {
@@ -507,12 +801,78 @@ 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;
if (mailbox.isShared || mailbox.id.startsWith('shared-')) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, mailbox });
}, []);
const handleCreateFolder = useCallback(async (name: string, parentId?: string) => {
const trimmed = name.trim();
if (!trimmed) {
toast.error(tFolder('folder_name_empty_error'));
return;
}
if (trimmed.includes('/')) {
toast.error(tFolder('folder_name_slash_error'));
return;
}
if (!client) return;
setCreatingSubfolder(null);
setCreatingTopLevel(false);
try {
await createMailbox(client, trimmed, parentId);
toast.success(tFolder('folder_created', { name: trimmed }));
if (parentId) {
setExpandedFolders(prev => {
const next = new Set(prev);
next.add(parentId);
return next;
});
}
} catch {
toast.error(tFolder('create_error'));
}
}, [client, createMailbox, tFolder]);
const handleRenameFolder = useCallback(async (newName: string) => {
const trimmed = newName.trim();
if (!renamingMailboxId || !client) {
setRenamingMailboxId(null);
return;
}
const mailbox = mailboxes.find(mb => mb.id === renamingMailboxId);
if (!trimmed || trimmed === mailbox?.name) {
setRenamingMailboxId(null);
return;
}
if (trimmed.includes('/')) {
toast.error(tFolder('folder_name_slash_error'));
setRenamingMailboxId(null);
return;
}
const targetId = renamingMailboxId;
setRenamingMailboxId(null);
try {
await renameMailbox(client, targetId, trimmed);
toast.success(tFolder('folder_renamed', { name: trimmed }));
} catch {
toast.error(tFolder('rename_error'));
}
}, [renamingMailboxId, client, mailboxes, renameMailbox, tFolder]);
const handleDeleteFolder = useCallback(async () => {
if (!deleteFolderTarget || !client) return;
const folderName = deleteFolderTarget.name;
const targetId = deleteFolderTarget.id;
setDeleteFolderTarget(null);
try {
await deleteMailbox(client, targetId);
toast.success(tFolder('folder_deleted', { name: folderName }));
} catch {
toast.error(tFolder('delete_error'));
}
}, [deleteFolderTarget, client, deleteMailbox, tFolder]);
const handleEmptyFolder = useCallback(async () => {
if (!emptyFolderTarget || !client) return;
const folderName = emptyFolderTarget.name;
@@ -565,8 +925,23 @@ export function Sidebar({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedMailbox, isCollapsed, expandedFolders, mailboxTree]);
useEffect(() => {
const handleF2 = (e: KeyboardEvent) => {
if (e.key !== 'F2' || !selectedMailbox) return;
if (!sidebarRef.current?.contains(document.activeElement) && document.activeElement !== document.body) return;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
if (!mailbox || mailbox.role || mailbox.isShared || mailbox.id.startsWith('shared-')) return;
e.preventDefault();
setRenamingMailboxId(selectedMailbox);
};
window.addEventListener('keydown', handleF2);
return () => window.removeEventListener('keydown', handleF2);
}, [selectedMailbox, mailboxes]);
return (
<div
ref={sidebarRef}
className={cn(
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
"bg-secondary border-border",
@@ -598,10 +973,21 @@ export function Sidebar({
</Button>
{!isCollapsed && (
<Button onClick={onCompose} className="flex-1" title={t("compose_hint")}>
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
<>
<Button onClick={onCompose} className="flex-1" title={t("compose_hint")}>
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setCreatingTopLevel(true)}
title={tFolder("new_folder")}
className="flex-shrink-0"
>
<Plus className="w-4 h-4" />
</Button>
</>
)}
</div>
@@ -642,7 +1028,32 @@ export function Sidebar({
)}
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto">
<div
className="flex-1 overflow-y-auto"
onDragOver={(e) => {
if (dragType === 'mailbox') {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
}}
onDrop={async (e) => {
if (dragType !== 'mailbox') return;
e.preventDefault();
const mailboxId = e.dataTransfer.getData("application/x-mailbox-id");
if (!mailboxId || !client) return;
const mb = mailboxes.find(m => m.id === mailboxId);
if (mb && mb.parentId) {
try {
await moveMailbox(client, mailboxId, null);
toast.success(tFolder('folder_moved_to_root'));
} catch {
toast.error(tFolder('move_error'));
}
}
globalEndDrag();
}}
>
<div className="py-1">
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
@@ -660,8 +1071,24 @@ export function Sidebar({
onToggleExpand={handleToggleExpand}
onMailboxContextMenu={handleMailboxContextMenu}
isCollapsed={isCollapsed}
renamingMailboxId={renamingMailboxId}
onRenameSubmit={handleRenameFolder}
onRenameCancel={() => setRenamingMailboxId(null)}
onStartRename={(id) => setRenamingMailboxId(id)}
creatingSubfolder={creatingSubfolder}
onCreateSubmit={(name) => handleCreateFolder(name, creatingSubfolder?.parentId)}
onCreateCancel={() => setCreatingSubfolder(null)}
/>
))}
{creatingTopLevel && !isCollapsed && (
<InlineInput
placeholder={tFolder('folder_name_placeholder')}
hintText={tFolder('enter_to_create')}
onSubmit={(name) => handleCreateFolder(name)}
onCancel={() => setCreatingTopLevel(false)}
depth={0}
/>
)}
</>
)}
</div>
@@ -670,8 +1097,8 @@ export function Sidebar({
<TagsSection isCollapsed={isCollapsed} onSearch={onSearch} />
</div>
{/* Mailbox Context Menu */}
{contextMenu && (
{/* Mailbox Context Menu (portal to escape sidebar overflow) */}
{contextMenu && createPortal(
<>
<div
className="fixed inset-0 z-40"
@@ -679,21 +1106,105 @@ export function Sidebar({
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 }}
ref={contextMenuRef}
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[160px]"
style={{
left: Math.min(contextMenu.x, window.innerWidth - 220),
top: Math.min(contextMenu.y, window.innerHeight - 300),
}}
>
<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>
{/* New subfolder */}
{contextMenu.mailbox.myRights?.mayCreateChild && (
<button
onClick={() => {
setCreatingSubfolder({ parentId: contextMenu.mailbox.id });
setExpandedFolders(prev => {
const next = new Set(prev);
next.add(contextMenu.mailbox.id);
return next;
});
setContextMenu(null);
}}
className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted transition-colors"
>
<FolderPlus className="w-4 h-4 mr-2" />
{tFolder("new_subfolder")}
</button>
)}
{/* Rename */}
{!contextMenu.mailbox.role && (
<button
onClick={() => {
setRenamingMailboxId(contextMenu.mailbox.id);
setContextMenu(null);
}}
className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted transition-colors"
>
<Edit3 className="w-4 h-4 mr-2" />
{tFolder("rename")}
</button>
)}
{/* Move to */}
{!contextMenu.mailbox.role && (
<MoveToSubmenu
mailbox={contextMenu.mailbox}
allMailboxes={mailboxes}
onMove={async (targetId) => {
if (!client) return;
try {
if (targetId === null) {
await moveMailbox(client, contextMenu.mailbox.id, null);
toast.success(tFolder('folder_moved_to_root'));
} else {
const target = mailboxes.find(mb => mb.id === targetId);
await moveMailbox(client, contextMenu.mailbox.id, targetId);
toast.success(tFolder('folder_moved', { destination: target?.name || '' }));
}
} catch {
toast.error(tFolder('move_error'));
}
setContextMenu(null);
}}
onClose={() => setContextMenu(null)}
/>
)}
{/* Empty folder (trash/junk only) */}
{(contextMenu.mailbox.role === "trash" || contextMenu.mailbox.role === "junk") &&
contextMenu.mailbox.totalEmails && contextMenu.mailbox.totalEmails > 0 && (
<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>
)}
{/* Delete folder */}
{!contextMenu.mailbox.role && (
<>
<div className="h-px bg-border mx-2 my-1" />
<button
onClick={() => {
setDeleteFolderTarget(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" />
{tFolder("delete_folder")}
</button>
</>
)}
</div>
</>
</>,
document.body
)}
{/* Empty Folder Confirmation Dialog */}
@@ -708,6 +1219,34 @@ export function Sidebar({
/>
)}
{/* Delete Folder Confirmation Dialog (portal to escape sidebar overflow) */}
{deleteFolderTarget && createPortal((() => {
const descendantCount = mailboxes.filter(mb => {
const isDesc = (parentId: string, checkId: string): boolean => {
if (parentId === checkId) return true;
const children = mailboxes.filter(m => m.parentId === parentId);
return children.some(c => isDesc(c.id, checkId));
};
return mb.id !== deleteFolderTarget.id && isDesc(deleteFolderTarget.id, mb.id);
}).length;
const emailCount = deleteFolderTarget.totalEmails || 0;
const message = emailCount > 0 || descendantCount > 0
? tFolder('delete_confirm_with_contents', { emails: emailCount, subfolders: descendantCount })
: tFolder('delete_confirm_empty');
return (
<ConfirmDialog
isOpen={true}
onClose={() => setDeleteFolderTarget(null)}
onConfirm={handleDeleteFolder}
title={tFolder('delete_confirm_title', { name: deleteFolderTarget.name })}
message={message}
confirmText={tFolder('delete_folder')}
variant="destructive"
/>
);
})(), document.body)}
{/* Footer: Storage Quota + Sign Out + Push Status */}
<div className="border-t border-border">
<StorageQuota quota={quota ?? null} isCollapsed={isCollapsed} />
+2 -3
View File
@@ -41,9 +41,8 @@ export function IntlProvider({ locale: initialLocale, children }: IntlProviderPr
try {
const detectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
setTimeZone(detectedTimeZone);
} catch (error) {
} catch {
// Fallback to UTC if detection fails
console.warn('Failed to detect timezone, using UTC:', error);
setTimeZone('UTC');
}
}, []);
@@ -53,7 +52,7 @@ export function IntlProvider({ locale: initialLocale, children }: IntlProviderPr
if (!currentLocale) {
setLocale(initialLocale);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync initial locale to store once on mount
}, []);
// Switch locale immediately when store changes
+1 -2
View File
@@ -103,8 +103,7 @@ export function VacationSettings() {
textBody: localTextBody,
});
toast.success(tNotifications('vacation_saved'));
} catch (error) {
console.error('Failed to save vacation response:', error);
} catch {
toast.error(tNotifications('vacation_save_failed'));
}
};
+23 -3
View File
@@ -3,23 +3,30 @@
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
import { Email } from "@/lib/jmap/types";
type DragType = 'email' | 'mailbox';
interface DragDropState {
isDragging: boolean;
dragType: DragType | null;
draggedEmails: Email[];
dragCount: number;
sourceMailboxId: string | null;
draggedMailboxId: string | null;
}
interface DragDropContextValue extends DragDropState {
startDrag: (emails: Email[], sourceMailboxId: string) => void;
startDrag: (emails: Email[], sourceMailboxId: string, type?: DragType) => void;
startMailboxDrag: (mailboxId: string) => void;
endDrag: () => void;
}
const initialState: DragDropState = {
isDragging: false,
dragType: null,
draggedEmails: [],
dragCount: 0,
sourceMailboxId: null,
draggedMailboxId: null,
};
const DragDropContext = createContext<DragDropContextValue | null>(null);
@@ -27,12 +34,25 @@ const DragDropContext = createContext<DragDropContextValue | null>(null);
export function DragDropProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<DragDropState>(initialState);
const startDrag = useCallback((emails: Email[], sourceMailboxId: string) => {
const startDrag = useCallback((emails: Email[], sourceMailboxId: string, type: DragType = 'email') => {
setState({
isDragging: true,
dragType: type,
draggedEmails: emails,
dragCount: emails.length,
sourceMailboxId,
draggedMailboxId: null,
});
}, []);
const startMailboxDrag = useCallback((mailboxId: string) => {
setState({
isDragging: true,
dragType: 'mailbox',
draggedEmails: [],
dragCount: 0,
sourceMailboxId: null,
draggedMailboxId: mailboxId,
});
}, []);
@@ -41,7 +61,7 @@ export function DragDropProvider({ children }: { children: ReactNode }) {
}, []);
return (
<DragDropContext.Provider value={{ ...state, startDrag, endDrag }}>
<DragDropContext.Provider value={{ ...state, startDrag, startMailboxDrag, endDrag }}>
{children}
</DragDropContext.Provider>
);
+1 -1
View File
@@ -103,7 +103,7 @@ export function useCalendarAlerts() {
const timer = setTimeout(() => checkAlerts(), 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when auth or notification setting changes, not when callback refs update
}, [isAuthenticated, calendarNotificationsEnabled]);
useEffect(() => {
+49 -26
View File
@@ -31,35 +31,29 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
const { isDragging, sourceMailboxId, draggedEmails, draggedMailboxId, dragType, endDrag } = useDragDropContext();
// Determine if this is a valid drop target
const isValidTarget = useCallback(() => {
if (!isDragging) return false;
// Cannot drop on same mailbox
if (mailbox.id === sourceMailboxId) return false;
// Check if mailbox accepts items
if (!mailbox.myRights?.mayAddItems) return false;
// Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false;
// For shared mailboxes, check account compatibility
if (mailbox.isShared && draggedEmails[0]) {
// Get the source mailbox's account ID from the store
const mailboxes = useEmailStore.getState().mailboxes;
const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId);
// Cross-account moves are not supported
if (sourceMb?.accountId !== mailbox.accountId) {
return false;
}
if (dragType === 'mailbox') {
if (mailbox.id === draggedMailboxId) return false;
if (mailbox.id.startsWith("shared-") || mailbox.isShared) return false;
if (!mailbox.myRights?.mayCreateChild) return false;
return true;
}
// Email drag validation (existing logic)
if (mailbox.id === sourceMailboxId) return false;
if (!mailbox.myRights?.mayAddItems) return false;
if (mailbox.id.startsWith("shared-")) return false;
if (mailbox.isShared && draggedEmails[0]) {
const mailboxes = useEmailStore.getState().mailboxes;
const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId);
if (sourceMb?.accountId !== mailbox.accountId) return false;
}
return true;
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
}, [isDragging, mailbox, sourceMailboxId, draggedEmails, draggedMailboxId, dragType]);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
@@ -97,6 +91,38 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
return;
}
// Handle mailbox drop (reparenting)
const mailboxIdData = e.dataTransfer.getData("application/x-mailbox-id");
if (mailboxIdData && dragType === 'mailbox') {
try {
const { moveMailbox } = useEmailStore.getState();
const allMailboxes = useEmailStore.getState().mailboxes;
const isDescendant = (parentId: string, checkId: string): boolean => {
const children = allMailboxes.filter(mb => mb.parentId === parentId);
return children.some(c => c.id === checkId || isDescendant(c.id, checkId));
};
if (isDescendant(mailboxIdData, mailbox.id)) {
endDrag();
return;
}
await moveMailbox(client, mailboxIdData, mailbox.id);
if (onSuccess) {
onSuccess(1, mailbox.name);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : 'Unknown error');
}
} finally {
endDrag();
}
return;
}
try {
const emailIdsJson = e.dataTransfer.getData("application/x-email-ids");
if (!emailIdsJson) {
@@ -136,9 +162,6 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
onDropComplete?.();
} catch (error) {
console.error("Failed to move emails:", error);
// Call error callback if provided, otherwise use fallback
if (onError) {
onError(error instanceof Error ? error.message : 'Unknown error');
} else {
@@ -148,7 +171,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally {
endDrag();
}
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
}, [client, mailbox, isValidTarget, dragType, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();
+8 -12
View File
@@ -273,26 +273,22 @@ describe('buildParticipantMap', () => {
expect(Object.keys(map)).toHaveLength(3);
const org = map['organizer'];
const org = map['mailto:alice@example.com'];
expect(org.name).toBe('Alice');
expect(org.email).toBe('alice@example.com');
expect(org.roles).toEqual({ owner: true, attendee: true });
expect(org.calendarAddress).toBe('mailto:alice@example.com');
expect(org.roles).toEqual({ owner: true });
expect(org.participationStatus).toBe('accepted');
expect(org.scheduleAgent).toBe('server');
expect(org.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
expect(org.expectReply).toBe(false);
const att0 = map['attendee-0'];
const att0 = map['mailto:bob@example.com'];
expect(att0.name).toBe('Bob');
expect(att0.email).toBe('bob@example.com');
expect(att0.calendarAddress).toBe('mailto:bob@example.com');
expect(att0.roles).toEqual({ attendee: true });
expect(att0.participationStatus).toBe('needs-action');
expect(att0.scheduleAgent).toBe('server');
expect(att0.expectReply).toBe(true);
const att1 = map['attendee-1'];
const att1 = map['mailto:carol@example.com'];
expect(att1.name).toBe('Carol');
expect(att1.email).toBe('carol@example.com');
expect(att1.calendarAddress).toBe('mailto:carol@example.com');
});
it('creates only organizer when no attendees', () => {
@@ -301,7 +297,7 @@ describe('buildParticipantMap', () => {
[]
);
expect(Object.keys(map)).toHaveLength(1);
expect(map['organizer']).toBeDefined();
expect(map['mailto:alice@example.com']).toBeDefined();
});
it('sets @type to Participant for all entries', () => {
-4
View File
@@ -54,7 +54,6 @@ describe('oauth/discovery', () => {
});
it('returns null when both endpoints fail', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({ ok: false, status: 404 })
.mockResolvedValueOnce({ ok: false, status: 404 }));
@@ -62,7 +61,6 @@ describe('oauth/discovery', () => {
const result = await discoverOAuth('https://fail.example.com');
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
});
it('parses optional fields (revocation_endpoint, end_session_endpoint)', async () => {
@@ -78,7 +76,6 @@ describe('oauth/discovery', () => {
});
it('returns null when required fields (authorization_endpoint, token_endpoint) are missing', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({
ok: true,
@@ -89,7 +86,6 @@ describe('oauth/discovery', () => {
const result = await discoverOAuth('https://incomplete.example.com');
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
});
it('caches results — second call for same server URL does not re-fetch', async () => {
+2 -2
View File
@@ -155,7 +155,7 @@ export function transformBgColorForDarkMode(colorString: string): string {
if (luminance < 0.2) return colorString;
const blendFactor = Math.min(0.9, (luminance - 0.2) * 1.125);
const darkR = 30, darkG = 31, darkB = 38;
const darkR = 35, darkG = 35, darkB = 35;
const r = Math.max(0, Math.round(rgb.r + (darkR - rgb.r) * blendFactor));
const g = Math.max(0, Math.round(rgb.g + (darkG - rgb.g) * blendFactor));
@@ -244,7 +244,7 @@ export function generateIframeStylesheet(): string {
@media (prefers-color-scheme: dark) {
body {
color: #e2e8f0;
background: #1e1f26;
background: #232323;
}
a { color: #60a5fa; }
img { opacity: 0.9; }
+1 -1
View File
@@ -170,7 +170,7 @@ export function getSecurityStatus(result?: string): {
}
/**
* Parse X-Spam-LLM header to extract AI verdict and explanation
* Parse X-Spam-LLM header to extract verdict and explanation
*/
export function parseSpamLLM(header: string): { verdict: string; explanation: string } | null {
// Format: "LEGITIMATE (explanation)" or "SPAM (explanation)"
+126 -76
View File
@@ -49,13 +49,24 @@ interface JMAPEmailHeader {
type JMAPMethodCall = [string, Record<string, unknown>, string];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JMAP responses have schema-variable nested payloads
type JMAPResponseResult = Record<string, any>;
interface JMAPResponse {
methodResponses: Array<[string, JMAPResponseResult, string]>;
}
export class JMAPSetError extends Error {
type: string;
description?: string;
constructor(type: string, description?: string) {
super(description || `Mailbox/set error: ${type}`);
this.name = 'JMAPSetError';
this.type = type;
this.description = description;
}
}
const DEFAULT_MAILBOX_RIGHTS = {
mayReadItems: true,
mayAddItems: true,
@@ -256,12 +267,11 @@ export class JMAPClient {
this.pingInterval = setInterval(async () => {
try {
await this.ping();
} catch (error) {
console.error('Keep-alive ping failed:', error);
} catch {
try {
await this.reconnect();
} catch (reconnectError) {
console.error('Reconnection failed:', reconnectError);
} catch {
return;
}
}
}, 30_000);
@@ -347,7 +357,6 @@ export class JMAPClient {
const responseText = await response.text();
if (!response.ok) {
console.error('Request failed:', response.status, responseText);
throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
}
@@ -355,7 +364,6 @@ export class JMAPClient {
try {
data = JSON.parse(responseText);
} catch {
console.error('Failed to parse response:', responseText);
throw new Error('Invalid JSON response from server');
}
@@ -417,8 +425,7 @@ export class JMAPClient {
}
throw new Error('Unexpected response format');
} catch (error) {
console.error('Failed to get mailboxes:', error);
} catch {
return [{
id: 'INBOX',
originalId: undefined,
@@ -481,14 +488,13 @@ export class JMAPClient {
allMailboxes.push(...mailboxes);
}
} catch (error) {
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
} catch {
continue;
}
}
return allMailboxes;
} catch (error) {
console.error("Failed to fetch all mailboxes:", error);
} catch {
return this.getMailboxes();
}
}
@@ -532,8 +538,7 @@ export class JMAPClient {
}
return { emails: [], hasMore: false, total: 0 };
} catch (error) {
console.error('Failed to get emails:', error);
} catch {
return { emails: [], hasMore: false, total: 0 };
}
}
@@ -576,8 +581,7 @@ export class JMAPClient {
}
return email;
} catch (error) {
console.error('Failed to get email:', error);
} catch {
return null;
}
}
@@ -769,6 +773,59 @@ export class JMAPClient {
]);
}
async createMailbox(name: string, parentId?: string): Promise<string> {
const create: Record<string, unknown> = { name };
if (parentId) create.parentId = parentId;
const response = await this.request([
["Mailbox/set", {
accountId: this.accountId,
create: { "new-mailbox": create },
}, "0"],
]);
const result = response.methodResponses?.[0]?.[1];
if (result?.notCreated?.["new-mailbox"]) {
const err = result.notCreated["new-mailbox"];
throw new JMAPSetError(err.type || "unknown", err.description);
}
const realId = result?.created?.["new-mailbox"]?.id;
if (!realId) throw new JMAPSetError("unknown", "Server did not return created mailbox ID");
return realId;
}
async updateMailbox(id: string, changes: { name?: string; parentId?: string | null }): Promise<void> {
const response = await this.request([
["Mailbox/set", {
accountId: this.accountId,
update: { [id]: changes },
}, "0"],
]);
const result = response.methodResponses?.[0]?.[1];
if (result?.notUpdated?.[id]) {
const err = result.notUpdated[id];
throw new JMAPSetError(err.type || "unknown", err.description);
}
}
async destroyMailbox(id: string): Promise<void> {
const response = await this.request([
["Mailbox/set", {
accountId: this.accountId,
destroy: [id],
onDestroyRemoveEmails: false,
}, "0"],
]);
const result = response.methodResponses?.[0]?.[1];
if (result?.notDestroyed?.[id]) {
const err = result.notDestroyed[id];
throw new JMAPSetError(err.type || "unknown", err.description);
}
}
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
const response = await this.request([
@@ -863,8 +920,7 @@ export class JMAPClient {
const hasMore = computeHasMore(position, emails.length, total, limit);
return { emails, hasMore, total };
} catch (error) {
console.error('Search failed:', error);
} catch {
return { emails: [], hasMore: false, total: 0 };
}
}
@@ -875,34 +931,29 @@ export class JMAPClient {
limit: number = 50,
position: number = 0
): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
const targetAccountId = accountId || this.accountId;
const targetAccountId = accountId || this.accountId;
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter,
sort: [{ property: "receivedAt", isAscending: false }],
limit,
position,
}, "0"],
["Email/get", {
accountId: targetAccountId,
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: [...EMAIL_LIST_PROPERTIES],
}, "1"],
]);
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter,
sort: [{ property: "receivedAt", isAscending: false }],
limit,
position,
}, "0"],
["Email/get", {
accountId: targetAccountId,
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: [...EMAIL_LIST_PROPERTIES],
}, "1"],
]);
const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || [];
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || [];
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
return { emails, hasMore, total };
} catch (error) {
console.error('Advanced search failed:', error);
throw error;
}
return { emails, hasMore, total };
}
async getThread(threadId: string, accountId?: string): Promise<Thread | null> {
@@ -922,8 +973,7 @@ export class JMAPClient {
}
return null;
} catch (error) {
console.error('Failed to get thread:', error);
} catch {
return null;
}
}
@@ -957,27 +1007,29 @@ export class JMAPClient {
}
return [];
} catch (error) {
console.error('Failed to get thread emails:', error);
} catch {
return [];
}
}
private submissionUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission"];
}
async getIdentities(): Promise<Identity[]> {
try {
const response = await this.request([
["Identity/get", {
accountId: this.accountId,
}, "0"]
]);
], this.submissionUsing());
if (response.methodResponses?.[0]?.[0] === "Identity/get") {
return (response.methodResponses[0][1].list || []) as Identity[];
}
return [];
} catch (error) {
console.error('Failed to get identities:', error);
} catch {
return [];
}
}
@@ -1004,7 +1056,7 @@ export class JMAPClient {
}
}
}, "0"]
]);
], this.submissionUsing());
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
@@ -1045,7 +1097,7 @@ export class JMAPClient {
[identityId]: updates
}
}, "0"]
]);
], this.submissionUsing());
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
@@ -1072,7 +1124,7 @@ export class JMAPClient {
accountId: this.accountId,
destroy: [identityId]
}, "0"]
]);
], this.submissionUsing());
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
@@ -1225,7 +1277,6 @@ export class JMAPClient {
if (result.notCreated || result.notUpdated) {
const errors = result.notCreated || result.notUpdated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Draft save error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft');
}
@@ -1234,7 +1285,6 @@ export class JMAPClient {
}
}
console.error('Unexpected draft save response:', response);
throw new Error('Failed to save draft');
}
@@ -1260,7 +1310,7 @@ export class JMAPClient {
if (!finalIdentityId) {
const identityResponse = await this.request([
["Identity/get", { accountId: this.accountId }, "0"]
]);
], this.submissionUsing());
finalIdentityId = this.accountId;
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
@@ -1312,19 +1362,17 @@ export class JMAPClient {
}, "1"]);
}
const response = await this.request(methodCalls);
const response = await this.request(methodCalls, this.submissionUsing());
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) {
console.error('JMAP method error:', result);
throw new Error(result.description || `Failed to send email: ${result.type}`);
}
if (result.notCreated || result.notUpdated) {
const errors = result.notCreated || result.notUpdated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Email send error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to send email');
}
}
@@ -1396,6 +1444,16 @@ export class JMAPClient {
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
}
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await this.authenticatedFetch(url, {}, { retry: false });
if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`);
}
const blob = await response.blob();
return URL.createObjectURL(blob);
}
getCapabilities(): Record<string, unknown> {
return this.capabilities;
}
@@ -1699,8 +1757,7 @@ export class JMAPClient {
return (response.methodResponses[0][1].list || []) as AddressBook[];
}
return [];
} catch (error) {
console.error('Failed to get address books:', error);
} catch {
return [];
}
}
@@ -1725,8 +1782,7 @@ export class JMAPClient {
return (response.methodResponses[1][1].list || []) as ContactCard[];
}
return [];
} catch (error) {
console.error('Failed to get contacts:', error);
} catch {
return [];
}
}
@@ -1746,8 +1802,7 @@ export class JMAPClient {
return list[0] || null;
}
return null;
} catch (error) {
console.error('Failed to get contact:', error);
} catch {
return null;
}
}
@@ -1861,8 +1916,7 @@ export class JMAPClient {
return (response.methodResponses[1][1].list || []) as ContactCard[];
}
return [];
} catch (error) {
console.error('Failed to search contacts:', error);
} catch {
return [];
}
}
@@ -1878,8 +1932,7 @@ export class JMAPClient {
return (response.methodResponses[0][1].list || []) as Calendar[];
}
return [];
} catch (error) {
console.error('Failed to get calendars:', error);
} catch {
return [];
}
}
@@ -1985,8 +2038,7 @@ export class JMAPClient {
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
}
return [];
} catch (error) {
console.error('Failed to get calendar events:', error);
} catch {
return [];
}
}
@@ -2021,8 +2073,7 @@ export class JMAPClient {
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
}
return [];
} catch (error) {
console.error('Failed to query calendar events:', error);
} catch {
return [];
}
}
@@ -2043,8 +2094,7 @@ export class JMAPClient {
return list[0] || null;
}
return null;
} catch (error) {
console.error('Failed to get calendar event:', error);
} catch {
return null;
}
}
-1
View File
@@ -48,6 +48,5 @@ export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata |
}
}
console.error(`[OAuth] Discovery failed for ${serverUrl}: ${errors.join('; ')}`);
return null;
}
+53 -1
View File
@@ -100,6 +100,37 @@
"progress": "Lösche... {deleted}/{total}",
"error": "Es konnten nicht alle E-Mails gelöscht werden. {deleted} von {total} wurden entfernt.",
"cancel": "Abbrechen"
},
"folder_management": {
"new_folder": "Neuer Ordner",
"new_subfolder": "Neuer Unterordner",
"rename": "Umbenennen",
"move_to": "Verschieben nach...",
"delete_folder": "Ordner löschen",
"delete_confirm_title": "\"{name}\" löschen?",
"delete_confirm_with_contents": "Dieser Ordner enthält {emails} E-Mails und {subfolders} Unterordner. Alle E-Mails werden in den Papierkorb verschoben.",
"delete_confirm_empty": "Dieser Ordner ist leer. Er wird dauerhaft gelöscht.",
"folder_created": "Ordner \"{name}\" erstellt",
"folder_renamed": "Ordner umbenannt in \"{name}\"",
"folder_moved": "Ordner verschoben nach \"{destination}\"",
"folder_moved_to_root": "Ordner in die oberste Ebene verschoben",
"folder_deleted": "Ordner \"{name}\" gelöscht",
"folder_name_placeholder": "Ordnername...",
"folder_name_empty_error": "Der Ordnername darf nicht leer sein",
"folder_name_exists_error": "Ein Ordner mit diesem Namen existiert bereits",
"folder_name_slash_error": "Ordnernamen dürfen kein / enthalten",
"folder_name_too_long_error": "Der Ordnername ist zu lang (max. 200 Zeichen)",
"create_error": "Ordner konnte nicht erstellt werden",
"rename_error": "Ordner konnte nicht umbenannt werden",
"move_error": "Ordner konnte nicht verschoben werden",
"delete_error": "Ordner konnte nicht gelöscht werden",
"permission_error": "Sie haben keine Berechtigung für diese Aktion",
"no_permission": "Keine Berechtigung",
"drop_to_move_inside": "Ablegen zum Verschieben hinein",
"enter_to_confirm": "Enter zum Bestätigen · Escape zum Abbrechen",
"enter_to_create": "Enter zum Erstellen · Escape zum Abbrechen",
"no_available_targets": "Keine verfügbaren Ordner",
"move_to_top_level": "Oberste Ebene"
}
},
"email_list": {
@@ -122,7 +153,28 @@
"mark_read": "Als gelesen markieren",
"mark_unread": "Als ungelesen markieren",
"delete": "Löschen",
"clear_selection": "Auswahl aufheben"
"clear_selection": "Auswahl aufheben",
"delete_confirm_title": "E-Mails löschen?",
"delete_confirm_message": "Möchten Sie {count, plural, one {# E-Mail} other {# E-Mails}} wirklich löschen?",
"move_to": "Verschieben nach...",
"move_success": "{count} E-Mails nach {folder} verschoben",
"move_error": "E-Mails konnten nicht verschoben werden",
"selected_count": "{count, plural, one {# E-Mail} other {# E-Mails}} ausgewählt"
},
"select_criteria": {
"label": "Auswahlkriterien",
"all": "Alle",
"none": "Keine",
"read": "Gelesen",
"unread": "Ungelesen",
"starred": "Markiert",
"unstarred": "Nicht markiert"
},
"move_to": {
"search_placeholder": "Ordner suchen...",
"no_results": "Keine Ordner gefunden",
"no_other_folders": "Keine weiteren Ordner verfügbar",
"custom_folders": "Benutzerdefinierte Ordner"
}
},
"email_viewer": {
+53 -1
View File
@@ -100,6 +100,37 @@
"progress": "Deleting... {deleted}/{total}",
"error": "Failed to delete all emails. {deleted} of {total} were removed.",
"cancel": "Cancel"
},
"folder_management": {
"new_folder": "New folder",
"new_subfolder": "New subfolder",
"rename": "Rename",
"move_to": "Move to...",
"delete_folder": "Delete folder",
"delete_confirm_title": "Delete \"{name}\"?",
"delete_confirm_with_contents": "This folder contains {emails} emails and {subfolders} subfolders. All emails will be moved to Trash.",
"delete_confirm_empty": "This folder is empty. It will be permanently deleted.",
"folder_created": "Folder \"{name}\" created",
"folder_renamed": "Folder renamed to \"{name}\"",
"folder_moved": "Folder moved to \"{destination}\"",
"folder_moved_to_root": "Folder moved to top level",
"folder_deleted": "Folder \"{name}\" deleted",
"folder_name_placeholder": "Folder name...",
"folder_name_empty_error": "Folder name cannot be empty",
"folder_name_exists_error": "A folder with this name already exists",
"folder_name_slash_error": "Folder names cannot contain /",
"folder_name_too_long_error": "Folder name is too long (max 200 characters)",
"create_error": "Failed to create folder",
"rename_error": "Failed to rename folder",
"move_error": "Failed to move folder",
"delete_error": "Failed to delete folder",
"permission_error": "You don't have permission for this action",
"no_permission": "No permission",
"drop_to_move_inside": "Drop to move inside",
"enter_to_confirm": "Enter to confirm · Escape to cancel",
"enter_to_create": "Enter to create · Escape to cancel",
"no_available_targets": "No available folders",
"move_to_top_level": "Top level"
}
},
"email_list": {
@@ -122,7 +153,28 @@
"mark_read": "Mark as read",
"mark_unread": "Mark as unread",
"delete": "Delete",
"clear_selection": "Clear selection"
"clear_selection": "Clear selection",
"delete_confirm_title": "Delete emails?",
"delete_confirm_message": "Are you sure you want to delete {count, plural, one {# email} other {# emails}}?",
"move_to": "Move to...",
"move_success": "Moved {count} emails to {folder}",
"move_error": "Failed to move emails",
"selected_count": "{count, plural, one {# email} other {# emails}} selected"
},
"select_criteria": {
"label": "Selection criteria",
"all": "All",
"none": "None",
"read": "Read",
"unread": "Unread",
"starred": "Starred",
"unstarred": "Unstarred"
},
"move_to": {
"search_placeholder": "Search folders...",
"no_results": "No folders found",
"no_other_folders": "No other folders available",
"custom_folders": "Custom Folders"
}
},
"email_viewer": {
+53 -1
View File
@@ -100,6 +100,37 @@
"progress": "Eliminando... {deleted}/{total}",
"error": "No se pudieron eliminar todos los correos. Se eliminaron {deleted} de {total}.",
"cancel": "Cancelar"
},
"folder_management": {
"new_folder": "Nueva carpeta",
"new_subfolder": "Nueva subcarpeta",
"rename": "Renombrar",
"move_to": "Mover a...",
"delete_folder": "Eliminar carpeta",
"delete_confirm_title": "¿Eliminar \"{name}\"?",
"delete_confirm_with_contents": "Esta carpeta contiene {emails} correos y {subfolders} subcarpetas. Todos los correos se moverán a la papelera.",
"delete_confirm_empty": "Esta carpeta está vacía. Se eliminará permanentemente.",
"folder_created": "Carpeta \"{name}\" creada",
"folder_renamed": "Carpeta renombrada a \"{name}\"",
"folder_moved": "Carpeta movida a \"{destination}\"",
"folder_moved_to_root": "Carpeta movida al nivel superior",
"folder_deleted": "Carpeta \"{name}\" eliminada",
"folder_name_placeholder": "Nombre de la carpeta...",
"folder_name_empty_error": "El nombre de la carpeta no puede estar vacío",
"folder_name_exists_error": "Ya existe una carpeta con ese nombre",
"folder_name_slash_error": "El nombre de la carpeta no puede contener /",
"folder_name_too_long_error": "El nombre de la carpeta es demasiado largo (máx. 200 caracteres)",
"create_error": "No se pudo crear la carpeta",
"rename_error": "No se pudo renombrar la carpeta",
"move_error": "No se pudo mover la carpeta",
"delete_error": "No se pudo eliminar la carpeta",
"permission_error": "No tienes permiso para realizar esta acción",
"no_permission": "Sin permiso",
"drop_to_move_inside": "Soltar para mover dentro",
"enter_to_confirm": "Intro para confirmar · Escape para cancelar",
"enter_to_create": "Intro para crear · Escape para cancelar",
"no_available_targets": "No hay carpetas disponibles",
"move_to_top_level": "Nivel superior"
}
},
"email_list": {
@@ -122,7 +153,28 @@
"mark_read": "Marcar como leído",
"mark_unread": "Marcar como no leído",
"delete": "Eliminar",
"clear_selection": "Limpiar selección"
"clear_selection": "Limpiar selección",
"delete_confirm_title": "¿Eliminar correos?",
"delete_confirm_message": "¿Estás seguro de que quieres eliminar {count, plural, one {# correo} other {# correos}}?",
"move_to": "Mover a...",
"move_success": "{count} correos movidos a {folder}",
"move_error": "Error al mover los correos",
"selected_count": "{count, plural, one {# correo} other {# correos}} seleccionados"
},
"select_criteria": {
"label": "Criterios de selección",
"all": "Todos",
"none": "Ninguno",
"read": "Leídos",
"unread": "No leídos",
"starred": "Destacados",
"unstarred": "Sin destacar"
},
"move_to": {
"search_placeholder": "Buscar carpetas...",
"no_results": "No se encontraron carpetas",
"no_other_folders": "No hay otras carpetas disponibles",
"custom_folders": "Carpetas personalizadas"
}
},
"email_viewer": {
+53 -1
View File
@@ -100,6 +100,37 @@
"progress": "Suppression... {deleted}/{total}",
"error": "Échec de la suppression de tous les emails. {deleted} sur {total} ont été supprimés.",
"cancel": "Annuler"
},
"folder_management": {
"new_folder": "Nouveau dossier",
"new_subfolder": "Nouveau sous-dossier",
"rename": "Renommer",
"move_to": "Déplacer vers...",
"delete_folder": "Supprimer le dossier",
"delete_confirm_title": "Supprimer \"{name}\" ?",
"delete_confirm_with_contents": "Ce dossier contient {emails} e-mails et {subfolders} sous-dossiers. Tous les e-mails seront déplacés dans la corbeille.",
"delete_confirm_empty": "Ce dossier est vide. Il sera définitivement supprimé.",
"folder_created": "Dossier \"{name}\" créé",
"folder_renamed": "Dossier renommé en \"{name}\"",
"folder_moved": "Dossier déplacé vers \"{destination}\"",
"folder_moved_to_root": "Dossier déplacé au niveau racine",
"folder_deleted": "Dossier \"{name}\" supprimé",
"folder_name_placeholder": "Nom du dossier...",
"folder_name_empty_error": "Le nom du dossier ne peut pas être vide",
"folder_name_exists_error": "Un dossier avec ce nom existe déjà",
"folder_name_slash_error": "Le nom du dossier ne peut pas contenir /",
"folder_name_too_long_error": "Le nom du dossier est trop long (200 caractères max.)",
"create_error": "Impossible de créer le dossier",
"rename_error": "Impossible de renommer le dossier",
"move_error": "Impossible de déplacer le dossier",
"delete_error": "Impossible de supprimer le dossier",
"permission_error": "Vous n'avez pas la permission d'effectuer cette action",
"no_permission": "Pas de permission",
"drop_to_move_inside": "Déposer pour déplacer à l'intérieur",
"enter_to_confirm": "Entrée pour confirmer · Échap pour annuler",
"enter_to_create": "Entrée pour créer · Échap pour annuler",
"no_available_targets": "Aucun dossier disponible",
"move_to_top_level": "Niveau racine"
}
},
"email_list": {
@@ -122,7 +153,28 @@
"mark_read": "Marquer comme lu",
"mark_unread": "Marquer comme non lu",
"delete": "Supprimer",
"clear_selection": "Effacer la sélection"
"clear_selection": "Effacer la sélection",
"delete_confirm_title": "Supprimer les emails ?",
"delete_confirm_message": "Voulez-vous vraiment supprimer {count, plural, one {# email} other {# emails}} ?",
"move_to": "Déplacer vers...",
"move_success": "{count} emails déplacés vers {folder}",
"move_error": "Échec du déplacement des emails",
"selected_count": "{count, plural, one {# email} other {# emails}} sélectionnés"
},
"select_criteria": {
"label": "Critères de sélection",
"all": "Tous",
"none": "Aucun",
"read": "Lus",
"unread": "Non lus",
"starred": "Suivis",
"unstarred": "Non suivis"
},
"move_to": {
"search_placeholder": "Rechercher des dossiers...",
"no_results": "Aucun dossier trouvé",
"no_other_folders": "Aucun autre dossier disponible",
"custom_folders": "Dossiers personnalisés"
}
},
"email_viewer": {
+53 -1
View File
@@ -100,6 +100,37 @@
"progress": "Eliminazione... {deleted}/{total}",
"error": "Impossibile eliminare tutte le email. {deleted} su {total} sono state rimosse.",
"cancel": "Annulla"
},
"folder_management": {
"new_folder": "Nuova cartella",
"new_subfolder": "Nuova sottocartella",
"rename": "Rinomina",
"move_to": "Sposta in...",
"delete_folder": "Elimina cartella",
"delete_confirm_title": "Eliminare \"{name}\"?",
"delete_confirm_with_contents": "Questa cartella contiene {emails} email e {subfolders} sottocartelle. Tutte le email saranno spostate nel cestino.",
"delete_confirm_empty": "Questa cartella è vuota. Verrà eliminata definitivamente.",
"folder_created": "Cartella \"{name}\" creata",
"folder_renamed": "Cartella rinominata in \"{name}\"",
"folder_moved": "Cartella spostata in \"{destination}\"",
"folder_moved_to_root": "Cartella spostata al livello principale",
"folder_deleted": "Cartella \"{name}\" eliminata",
"folder_name_placeholder": "Nome cartella...",
"folder_name_empty_error": "Il nome della cartella non può essere vuoto",
"folder_name_exists_error": "Esiste già una cartella con questo nome",
"folder_name_slash_error": "Il nome della cartella non può contenere /",
"folder_name_too_long_error": "Il nome della cartella è troppo lungo (max 200 caratteri)",
"create_error": "Impossibile creare la cartella",
"rename_error": "Impossibile rinominare la cartella",
"move_error": "Impossibile spostare la cartella",
"delete_error": "Impossibile eliminare la cartella",
"permission_error": "Non hai i permessi per eseguire questa azione",
"no_permission": "Nessun permesso",
"drop_to_move_inside": "Rilascia per spostare all'interno",
"enter_to_confirm": "Invio per confermare · Esc per annullare",
"enter_to_create": "Invio per creare · Esc per annullare",
"no_available_targets": "Nessuna cartella disponibile",
"move_to_top_level": "Livello principale"
}
},
"email_list": {
@@ -122,7 +153,28 @@
"mark_read": "Segna come letto",
"mark_unread": "Segna come non letto",
"delete": "Elimina",
"clear_selection": "Cancella selezione"
"clear_selection": "Cancella selezione",
"delete_confirm_title": "Eliminare le email?",
"delete_confirm_message": "Sei sicuro di voler eliminare {count, plural, one {# email} other {# email}}?",
"move_to": "Sposta in...",
"move_success": "{count} email spostate in {folder}",
"move_error": "Spostamento email non riuscito",
"selected_count": "{count, plural, one {# email} other {# email}} selezionate"
},
"select_criteria": {
"label": "Criteri di selezione",
"all": "Tutte",
"none": "Nessuna",
"read": "Lette",
"unread": "Non lette",
"starred": "Contrassegnate",
"unstarred": "Non contrassegnate"
},
"move_to": {
"search_placeholder": "Cerca cartelle...",
"no_results": "Nessuna cartella trovata",
"no_other_folders": "Nessun'altra cartella disponibile",
"custom_folders": "Cartelle personalizzate"
}
},
"email_viewer": {
+53 -1
View File
@@ -100,6 +100,37 @@
"progress": "削除中... {deleted}/{total}",
"error": "すべてのメールを削除できませんでした。{total}件中{deleted}件が削除されました。",
"cancel": "キャンセル"
},
"folder_management": {
"new_folder": "新しいフォルダ",
"new_subfolder": "新しいサブフォルダ",
"rename": "名前を変更",
"move_to": "移動先...",
"delete_folder": "フォルダを削除",
"delete_confirm_title": "\"{name}\" を削除しますか?",
"delete_confirm_with_contents": "このフォルダには {emails} 件のメールと {subfolders} 個のサブフォルダが含まれています。すべてのメールはゴミ箱に移動されます。",
"delete_confirm_empty": "このフォルダは空です。完全に削除されます。",
"folder_created": "フォルダ \"{name}\" を作成しました",
"folder_renamed": "フォルダを \"{name}\" に変更しました",
"folder_moved": "フォルダを \"{destination}\" に移動しました",
"folder_moved_to_root": "フォルダをトップレベルに移動しました",
"folder_deleted": "フォルダ \"{name}\" を削除しました",
"folder_name_placeholder": "フォルダ名...",
"folder_name_empty_error": "フォルダ名を入力してください",
"folder_name_exists_error": "同じ名前のフォルダが既に存在します",
"folder_name_slash_error": "フォルダ名に / は使用できません",
"folder_name_too_long_error": "フォルダ名が長すぎます(最大200文字)",
"create_error": "フォルダの作成に失敗しました",
"rename_error": "フォルダの名前変更に失敗しました",
"move_error": "フォルダの移動に失敗しました",
"delete_error": "フォルダの削除に失敗しました",
"permission_error": "この操作を行う権限がありません",
"no_permission": "権限なし",
"drop_to_move_inside": "ドロップして内部に移動",
"enter_to_confirm": "Enter で確定 · Escape でキャンセル",
"enter_to_create": "Enter で作成 · Escape でキャンセル",
"no_available_targets": "利用可能なフォルダがありません",
"move_to_top_level": "トップレベル"
}
},
"email_list": {
@@ -122,7 +153,28 @@
"mark_read": "既読にする",
"mark_unread": "未読にする",
"delete": "削除",
"clear_selection": "選択を解除"
"clear_selection": "選択を解除",
"delete_confirm_title": "メールを削除しますか?",
"delete_confirm_message": "{count}件のメールを削除してもよろしいですか?",
"move_to": "移動先...",
"move_success": "{count}件のメールを{folder}に移動しました",
"move_error": "メールの移動に失敗しました",
"selected_count": "{count}件選択中"
},
"select_criteria": {
"label": "選択条件",
"all": "すべて",
"none": "なし",
"read": "既読",
"unread": "未読",
"starred": "スター付き",
"unstarred": "スターなし"
},
"move_to": {
"search_placeholder": "フォルダを検索...",
"no_results": "フォルダが見つかりません",
"no_other_folders": "他のフォルダがありません",
"custom_folders": "カスタムフォルダ"
}
},
"email_viewer": {
+53 -1
View File
@@ -100,6 +100,37 @@
"progress": "Verwijderen... {deleted}/{total}",
"error": "Niet alle e-mails konden worden verwijderd. {deleted} van {total} zijn verwijderd.",
"cancel": "Annuleren"
},
"folder_management": {
"new_folder": "Nieuwe map",
"new_subfolder": "Nieuwe submap",
"rename": "Naam wijzigen",
"move_to": "Verplaatsen naar...",
"delete_folder": "Map verwijderen",
"delete_confirm_title": "\"{name}\" verwijderen?",
"delete_confirm_with_contents": "Deze map bevat {emails} e-mails en {subfolders} submappen. Alle e-mails worden naar de prullenbak verplaatst.",
"delete_confirm_empty": "Deze map is leeg. De map wordt permanent verwijderd.",
"folder_created": "Map \"{name}\" aangemaakt",
"folder_renamed": "Map hernoemd naar \"{name}\"",
"folder_moved": "Map verplaatst naar \"{destination}\"",
"folder_moved_to_root": "Map naar het hoogste niveau verplaatst",
"folder_deleted": "Map \"{name}\" verwijderd",
"folder_name_placeholder": "Mapnaam...",
"folder_name_empty_error": "De mapnaam mag niet leeg zijn",
"folder_name_exists_error": "Er bestaat al een map met deze naam",
"folder_name_slash_error": "Mapnamen mogen geen / bevatten",
"folder_name_too_long_error": "De mapnaam is te lang (max. 200 tekens)",
"create_error": "Map kon niet worden aangemaakt",
"rename_error": "Map kon niet worden hernoemd",
"move_error": "Map kon niet worden verplaatst",
"delete_error": "Map kon niet worden verwijderd",
"permission_error": "U heeft geen toestemming voor deze actie",
"no_permission": "Geen toestemming",
"drop_to_move_inside": "Slepen om naar binnen te verplaatsen",
"enter_to_confirm": "Enter om te bevestigen · Escape om te annuleren",
"enter_to_create": "Enter om aan te maken · Escape om te annuleren",
"no_available_targets": "Geen beschikbare mappen",
"move_to_top_level": "Hoogste niveau"
}
},
"email_list": {
@@ -122,7 +153,28 @@
"mark_read": "Markeren als gelezen",
"mark_unread": "Markeren als ongelezen",
"delete": "Verwijderen",
"clear_selection": "Selectie wissen"
"clear_selection": "Selectie wissen",
"delete_confirm_title": "E-mails verwijderen?",
"delete_confirm_message": "Weet u zeker dat u {count, plural, one {# e-mail} other {# e-mails}} wilt verwijderen?",
"move_to": "Verplaatsen naar...",
"move_success": "{count} e-mails verplaatst naar {folder}",
"move_error": "E-mails verplaatsen mislukt",
"selected_count": "{count, plural, one {# e-mail} other {# e-mails}} geselecteerd"
},
"select_criteria": {
"label": "Selectiecriteria",
"all": "Alle",
"none": "Geen",
"read": "Gelezen",
"unread": "Ongelezen",
"starred": "Met ster",
"unstarred": "Zonder ster"
},
"move_to": {
"search_placeholder": "Mappen zoeken...",
"no_results": "Geen mappen gevonden",
"no_other_folders": "Geen andere mappen beschikbaar",
"custom_folders": "Aangepaste mappen"
}
},
"email_viewer": {
+53 -1
View File
@@ -100,6 +100,37 @@
"progress": "Excluindo... {deleted}/{total}",
"error": "Não foi possível excluir todos os e-mails. {deleted} de {total} foram removidos.",
"cancel": "Cancelar"
},
"folder_management": {
"new_folder": "Nova pasta",
"new_subfolder": "Nova subpasta",
"rename": "Renomear",
"move_to": "Mover para...",
"delete_folder": "Excluir pasta",
"delete_confirm_title": "Excluir \"{name}\"?",
"delete_confirm_with_contents": "Esta pasta contém {emails} e-mails e {subfolders} subpastas. Todos os e-mails serão movidos para a lixeira.",
"delete_confirm_empty": "Esta pasta está vazia. Ela será excluída permanentemente.",
"folder_created": "Pasta \"{name}\" criada",
"folder_renamed": "Pasta renomeada para \"{name}\"",
"folder_moved": "Pasta movida para \"{destination}\"",
"folder_moved_to_root": "Pasta movida para o nível superior",
"folder_deleted": "Pasta \"{name}\" excluída",
"folder_name_placeholder": "Nome da pasta...",
"folder_name_empty_error": "O nome da pasta não pode estar vazio",
"folder_name_exists_error": "Já existe uma pasta com esse nome",
"folder_name_slash_error": "O nome da pasta não pode conter /",
"folder_name_too_long_error": "O nome da pasta é muito longo (máx. 200 caracteres)",
"create_error": "Não foi possível criar a pasta",
"rename_error": "Não foi possível renomear a pasta",
"move_error": "Não foi possível mover a pasta",
"delete_error": "Não foi possível excluir a pasta",
"permission_error": "Você não tem permissão para esta ação",
"no_permission": "Sem permissão",
"drop_to_move_inside": "Soltar para mover para dentro",
"enter_to_confirm": "Enter para confirmar · Escape para cancelar",
"enter_to_create": "Enter para criar · Escape para cancelar",
"no_available_targets": "Nenhuma pasta disponível",
"move_to_top_level": "Nível superior"
}
},
"email_list": {
@@ -122,7 +153,28 @@
"mark_read": "Marcar como lido",
"mark_unread": "Marcar como não lido",
"delete": "Excluir",
"clear_selection": "Limpar seleção"
"clear_selection": "Limpar seleção",
"delete_confirm_title": "Excluir emails?",
"delete_confirm_message": "Tem certeza de que deseja excluir {count, plural, one {# email} other {# emails}}?",
"move_to": "Mover para...",
"move_success": "{count} emails movidos para {folder}",
"move_error": "Falha ao mover emails",
"selected_count": "{count, plural, one {# email} other {# emails}} selecionados"
},
"select_criteria": {
"label": "Critérios de seleção",
"all": "Todos",
"none": "Nenhum",
"read": "Lidos",
"unread": "Não lidos",
"starred": "Com estrela",
"unstarred": "Sem estrela"
},
"move_to": {
"search_placeholder": "Pesquisar pastas...",
"no_results": "Nenhuma pasta encontrada",
"no_other_folders": "Nenhuma outra pasta disponível",
"custom_folders": "Pastas personalizadas"
}
},
"email_viewer": {
+786 -774
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "jmap-webmail",
"version": "1.3.3",
"version": "1.4.0",
"description": "A modern JMAP webmail client built for Stalwart Mail Server",
"author": "Matthieu MALVACHE <matthieu@root.cloud>",
"license": "MIT",
@@ -57,15 +57,16 @@
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.16",
"@vitest/ui": "^4.0.18",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"flatted": "^3.4.2",
"globals": "^17.0.0",
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vitest": "^4.0.16"
"vitest": "^4.0.18"
}
}
+5 -8
View File
@@ -92,8 +92,7 @@ export const useContactStore = create<ContactStore>()(
try {
const contacts = await client.getContacts();
set({ contacts, isLoading: false });
} catch (error) {
console.error('Failed to fetch contacts:', error);
} catch {
set({ error: 'Failed to fetch contacts', isLoading: false });
}
},
@@ -102,8 +101,7 @@ export const useContactStore = create<ContactStore>()(
try {
const addressBooks = await client.getAddressBooks();
set({ addressBooks });
} catch (error) {
console.error('Failed to fetch address books:', error);
} catch {
set({ error: 'Failed to fetch address books' });
}
},
@@ -351,8 +349,7 @@ export const useContactStore = create<ContactStore>()(
for (const id of ids) {
try {
await client.deleteContact(id);
} catch (error) {
console.error(`Failed to delete contact ${id}:`, error);
} catch {
deletedIds.delete(id);
}
}
@@ -391,8 +388,8 @@ export const useContactStore = create<ContactStore>()(
set((state) => ({ contacts: [...state.contacts, localContact] }));
}
imported++;
} catch (error) {
console.error('Failed to import contact:', error);
} catch {
continue;
}
}
+261 -87
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { Email, Mailbox, StateChange, ThreadGroup } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
@@ -18,6 +18,7 @@ interface EmailStore {
quota: { used: number; total: number } | null;
processingReadStatus: Set<string>; // Track emails being marked as read/unread
selectedEmailIds: Set<string>; // Track selected emails for batch operations
lastSelectedIndex: number | null;
hasMoreEmails: boolean; // Track if more emails are available to load
totalEmails: number; // Total number of emails in the current mailbox/query
isPushConnected: boolean; // Track if push notifications are connected
@@ -43,9 +44,11 @@ interface EmailStore {
setError: (error: string | null) => void;
setSearchQuery: (query: string) => void;
setQuota: (quota: { used: number; total: number } | null) => void;
toggleEmailSelection: (emailId: string) => void;
selectAllEmails: () => void;
toggleEmailSelection: (emailId: string, groupIndex?: number) => void;
selectAllEmails: (threadGroups?: ThreadGroup[]) => void;
clearSelection: () => void;
selectRange: (fromIndex: number, toIndex: number, threadGroups: ThreadGroup[]) => void;
selectByFilter: (filter: string, threadGroups: ThreadGroup[]) => void;
// JMAP operations
fetchMailboxes: (client: JMAPClient) => Promise<void>;
@@ -96,6 +99,11 @@ interface EmailStore {
// Empty folder
emptyFolder: (client: JMAPClient, mailboxId: string, onProgress?: (deleted: number, total: number) => void) => Promise<void>;
createMailbox: (client: JMAPClient, name: string, parentId?: string) => Promise<string | null>;
renameMailbox: (client: JMAPClient, mailboxId: string, newName: string) => Promise<boolean>;
moveMailbox: (client: JMAPClient, mailboxId: string, newParentId: string | null) => Promise<boolean>;
deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise<boolean>;
// Mock data for demo
loadMockData: () => void;
}
@@ -113,6 +121,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
quota: null,
processingReadStatus: new Set(),
selectedEmailIds: new Set(),
lastSelectedIndex: null,
hasMoreEmails: false,
totalEmails: 0,
isPushConnected: false,
@@ -142,6 +151,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedMailbox: mailboxId,
selectedEmail: null,
selectedEmailIds: new Set(),
lastSelectedIndex: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
@@ -152,7 +162,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
setSearchQuery: (query) => set({ searchQuery: query }),
setQuota: (quota) => set({ quota }),
toggleEmailSelection: (emailId) => {
toggleEmailSelection: (emailId, groupIndex) => {
const { selectedEmailIds } = get();
const newSelection = new Set(selectedEmailIds);
if (newSelection.has(emailId)) {
@@ -160,22 +170,73 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} else {
newSelection.add(emailId);
}
set({ selectedEmailIds: newSelection });
set({
selectedEmailIds: newSelection,
lastSelectedIndex: groupIndex ?? get().lastSelectedIndex,
});
},
selectAllEmails: () => {
const { emails } = get();
const allIds = new Set(emails.map(e => e.id));
set({ selectedEmailIds: allIds });
selectAllEmails: (threadGroups) => {
if (threadGroups) {
const allIds = new Set(threadGroups.map(g => g.latestEmail.id));
set({ selectedEmailIds: allIds, lastSelectedIndex: null });
} else {
const { emails } = get();
const allIds = new Set(emails.map(e => e.id));
set({ selectedEmailIds: allIds });
}
},
clearSelection: () => {
set({ selectedEmailIds: new Set() });
set({ selectedEmailIds: new Set(), lastSelectedIndex: null });
},
selectRange: (fromIndex, toIndex, threadGroups) => {
const start = Math.min(fromIndex, toIndex);
const end = Math.max(fromIndex, toIndex);
const { selectedEmailIds } = get();
const newSelection = new Set(selectedEmailIds);
for (let i = start; i <= end; i++) {
const group = threadGroups[i];
if (group) {
newSelection.add(group.latestEmail.id);
}
}
set({ selectedEmailIds: newSelection, lastSelectedIndex: toIndex });
},
selectByFilter: (filter, threadGroups) => {
if (filter === 'none') {
set({ selectedEmailIds: new Set(), lastSelectedIndex: null });
return;
}
if (filter === 'all') {
const allIds = new Set(threadGroups.map(g => g.latestEmail.id));
set({ selectedEmailIds: allIds, lastSelectedIndex: null });
return;
}
const newSelection = new Set<string>();
for (const group of threadGroups) {
const email = group.latestEmail;
const seen = !!email.keywords?.$seen;
const flagged = !!email.keywords?.$flagged;
const match =
(filter === 'read' && seen) ||
(filter === 'unread' && !seen) ||
(filter === 'starred' && flagged) ||
(filter === 'unstarred' && !flagged);
if (match) {
newSelection.add(email.id);
}
}
set({ selectedEmailIds: newSelection, lastSelectedIndex: null });
},
// JMAP operations
fetchMailboxes: async (client) => {
set({ isLoading: true, error: null });
if (get().mailboxes.length === 0) {
set({ isLoading: true, error: null });
}
try {
const mailboxes = await client.getAllMailboxes();
@@ -224,7 +285,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isLoading: false
});
} catch (error) {
console.error('Failed to fetch emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to fetch emails",
isLoading: false,
@@ -283,7 +343,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isLoadingMore: false
});
} catch (error) {
console.error('Failed to load more emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more emails",
isLoadingMore: false
@@ -325,15 +384,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName) => {
set({ isLoading: true, error: null });
set({ error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName);
// Refresh handled by UI layer for immediate feedback
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
isLoading: false
});
throw error;
}
@@ -727,7 +783,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const { selectedEmailIds, emails, mailboxes } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
set({ error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMarkAsRead(emailIdsArray, read);
@@ -763,12 +819,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
emails: updatedEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to update emails",
isLoading: false
});
}
},
@@ -777,7 +831,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const { selectedEmailIds, emails, mailboxes } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
set({ error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchDeleteEmails(emailIdsArray);
@@ -814,12 +868,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
selectedEmail: null,
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to delete emails",
isLoading: false
});
}
},
@@ -828,7 +880,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const { selectedEmailIds, emails } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
set({ error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMoveEmails(emailIdsArray, toMailboxId);
@@ -839,15 +891,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({
emails: remainingEmails,
selectedEmailIds: new Set(),
isLoading: false
});
// Refresh emails to get updated list
await get().fetchEmails(client, get().selectedMailbox);
// Silent refresh to sync with server
await get().refreshCurrentMailbox(client);
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move emails",
isLoading: false
});
}
},
@@ -867,21 +917,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
accountId: currentMailbox.accountId,
});
try {
await client.markAsSpam(emailId, currentMailbox.accountId);
await client.markAsSpam(emailId, currentMailbox.accountId);
set(state => ({
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
}));
set(state => ({
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
}));
const currentIndex = emails.findIndex(e => e.id === emailId);
if (currentIndex >= 0 && currentIndex < emails.length - 1) {
set({ selectedEmail: emails[currentIndex + 1] });
}
} catch (error) {
console.error('Failed to mark as spam:', error);
throw error;
const currentIndex = emails.findIndex(e => e.id === emailId);
if (currentIndex >= 0 && currentIndex < emails.length - 1) {
set({ selectedEmail: emails[currentIndex + 1] });
}
},
@@ -917,13 +962,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
targetMailboxId = inboxMailbox.originalId || inboxMailbox.id;
}
try {
await client.undoSpam(emailId, targetMailboxId, accountId);
await get().fetchEmails(client, selectedMailbox);
} catch (error) {
console.error('Failed to restore email:', error);
throw error;
}
await client.undoSpam(emailId, targetMailboxId, accountId);
await get().fetchEmails(client, selectedMailbox);
},
batchMarkAsSpam: async (client, emailIds) => {
@@ -932,20 +972,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
try {
for (const emailId of emailIds) {
await client.markAsSpam(emailId, currentMailbox.accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
} catch (error) {
console.error('Failed to batch mark as spam:', error);
throw error;
for (const emailId of emailIds) {
await client.markAsSpam(emailId, currentMailbox.accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
},
batchUndoSpam: async (client: JMAPClient, emailIds: string[]) => {
@@ -964,20 +999,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
throw new Error('Inbox not found');
}
try {
for (const emailId of emailIds) {
await client.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
} catch (error) {
console.error('Failed to batch restore emails:', error);
throw error;
for (const emailId of emailIds) {
await client.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
},
// Push notification handlers
@@ -1025,13 +1055,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const { useFilterStore } = await import('./filter-store');
const filterStore = useFilterStore.getState();
if (filterStore.isSupported) {
filterStore.fetchFilters(client).catch((err) => {
console.error('Failed to refresh filters:', err);
});
filterStore.fetchFilters(client).catch(() => {});
}
}
} catch (error) {
console.error('Failed to handle state change:', error);
set({
error: error instanceof Error ? error.message : "Failed to handle push notification"
});
@@ -1088,10 +1115,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
totalEmails: result.total,
});
}
} catch (error) {
console.error('Failed to refresh current mailbox:', error);
// Don't set error state for background refreshes to avoid disrupting the UI
}
} catch { /* silent: push refresh is best-effort */ }
},
handleNewEmailNotification: (email) => {
@@ -1148,8 +1172,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
return emails;
} catch (error) {
console.error('Failed to fetch thread emails:', error);
} catch {
set({ isLoadingThread: null });
return [];
}
@@ -1173,9 +1196,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
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);
}
} catch { /* silent: tag counts are non-critical */ }
},
emptyFolder: async (client, mailboxId, onProgress) => {
@@ -1215,6 +1236,159 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
createMailbox: async (client, name, parentId) => {
const tempId = `temp-${Date.now()}`;
const mailboxes = get().mailboxes;
const tempMailbox: Mailbox = {
id: tempId,
name,
parentId: parentId || undefined,
sortOrder: 999,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true,
maySetSeen: true, maySetKeywords: true, mayCreateChild: true,
mayRename: true, mayDelete: true, maySubmit: false,
},
isSubscribed: true,
};
set({ mailboxes: [...mailboxes, tempMailbox] });
try {
const realId = await client.createMailbox(name, parentId);
set((state) => {
const updated = state.mailboxes.map(mb =>
mb.id === tempId ? { ...mb, id: realId } : mb
);
const newState: Partial<EmailStore> = { mailboxes: updated };
if (state.selectedMailbox === tempId) {
newState.selectedMailbox = realId;
}
return newState;
});
return realId;
} catch (error) {
set({ mailboxes: get().mailboxes.filter(mb => mb.id !== tempId) });
throw error;
}
},
renameMailbox: async (client, mailboxId, newName) => {
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return false;
const previousName = mailbox.name;
const jmapId = mailbox.originalId || mailboxId;
set({
mailboxes: mailboxes.map(mb =>
mb.id === mailboxId ? { ...mb, name: newName } : mb
),
});
try {
await client.updateMailbox(jmapId, { name: newName });
return true;
} catch (error) {
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId ? { ...mb, name: previousName } : mb
),
});
throw error;
}
},
moveMailbox: async (client, mailboxId, newParentId) => {
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return false;
const previousParentId = mailbox.parentId;
const jmapId = mailbox.originalId || mailboxId;
set({
mailboxes: mailboxes.map(mb =>
mb.id === mailboxId ? { ...mb, parentId: newParentId || undefined } : mb
),
});
try {
await client.updateMailbox(jmapId, { parentId: newParentId });
return true;
} catch (error) {
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId ? { ...mb, parentId: previousParentId } : mb
),
});
throw error;
}
},
deleteMailbox: async (client, mailboxId) => {
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return false;
if (get().selectedMailbox === mailboxId) {
const inbox = mailboxes.find(mb => mb.role === 'inbox');
if (inbox) set({ selectedMailbox: inbox.id });
}
const collectDescendants = (parentId: string): Mailbox[] => {
const children = mailboxes.filter(mb => mb.parentId === parentId);
const descendants: Mailbox[] = [];
for (const child of children) {
descendants.push(...collectDescendants(child.id));
descendants.push(child);
}
return descendants;
};
const descendants = collectDescendants(mailboxId);
const allToDelete = [...descendants, mailbox];
const allIds = new Set(allToDelete.map(mb => mb.id));
const trashMailbox = mailboxes.find(mb => mb.role === 'trash');
const trashId = trashMailbox?.originalId || trashMailbox?.id;
set({ mailboxes: mailboxes.filter(mb => !allIds.has(mb.id)) });
try {
for (const mb of allToDelete) {
const jmapId = mb.originalId || mb.id;
if (trashId && mb.totalEmails > 0) {
let position = 0;
while (true) {
const batch = await client.queryMailboxEmailIds(jmapId, 500, position);
if (batch.ids.length === 0) break;
await client.batchMoveEmails(batch.ids, trashId);
if (batch.ids.length < 500) break;
position = 0;
}
}
await client.destroyMailbox(jmapId);
}
await get().fetchMailboxes(client);
return true;
} catch (error) {
await get().fetchMailboxes(client);
throw error;
}
},
loadMockData: () => {
const mockEmails: Email[] = [
{
+1 -2
View File
@@ -187,8 +187,7 @@ export const useSettingsStore = create<SettingsState>()(
applySidebarWidth(get().sidebarWidth);
return true;
} catch (error) {
console.error('Failed to import settings:', error);
} catch {
return false;
}
},