mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-22 16:01:12 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ export default function LocaleError({
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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">✓</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">✕</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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+786
-774
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user