719ee251f2
Check Cross-Plugin Imports / check (push) Has been cancelled
- RCE: move _check_dangerous_imports() BEFORE exec_module() in plugins.py - verify_ws_origin: reject empty Origin header when CORS configured - Test: ai_app fixture with permission_registry init for ai_assistant - Test: login_client sets CSRF token + Origin as client default headers - Test: SESSION_COOKIE_SECURE=false override + get_settings.cache_clear() - Test: asyncio_default_test_loop_scope=session fixes event loop closed - Test: fix 15 assertions (paths, variables, auth expectations) - Frontend: integrate SavedFilterBar in ContactsList, Mail, Calendar - Frontend: integrate TagSelector in ContactsList, Mail, Calendar - Event Bus: add 4 subscribers in system_notif (conversation/participant/reaction) - Docs: update all analysis reports and FIX-PLAN-V2 to current state
1036 lines
35 KiB
TypeScript
1036 lines
35 KiB
TypeScript
/**
|
|
* Mail page — folder tree + mail list + reading pane + compose.
|
|
* Uses ResizablePanel for drag-to-resize columns.
|
|
* Account selection is integrated into the folder tree (left pane).
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Link } from 'react-router-dom';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { useToast } from '@/components/ui/Toast';
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
|
import { MailFolderTree } from '@/components/mail/MailFolderTree';
|
|
import { MailList } from '@/components/mail/MailList';
|
|
import { MailDetail } from '@/components/mail/MailDetail';
|
|
import { MailComposeForm, type ComposeMode } from '@/components/mail/MailComposeForm';
|
|
import { useWindowStore } from '@/store/windowStore';
|
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
|
import { ArrowRight, Check, ChevronLeft, ExternalLink, Loader2, Plus, Redo2, Trash2, TrendingUp, Undo2 } from 'lucide-react';
|
|
import { SavedFilterBar } from '@/components/common/SavedFilterBar';
|
|
import { TagSelector } from '@/components/tags/TagSelector';
|
|
import type { Tag } from '@/api/tags';
|
|
import {
|
|
fetchAccounts,
|
|
fetchFolders,
|
|
fetchMails,
|
|
getMail,
|
|
sendMail,
|
|
replyMail,
|
|
forwardMail,
|
|
updateFlags,
|
|
createEventFromMail,
|
|
downloadAttachment,
|
|
fetchSignatures,
|
|
searchMails,
|
|
deleteMail,
|
|
moveMail,
|
|
saveDraft,
|
|
updateDraft,
|
|
triggerSync,
|
|
syncFolder,
|
|
type MailAccount,
|
|
type MailFolder,
|
|
type Mail,
|
|
type MailSignature,
|
|
type MailAttachment,
|
|
type SendMailPayload,
|
|
type ReplyPayload,
|
|
type ForwardPayload,
|
|
type CreateEventFromMailPayload,
|
|
type MailDraftPayload,
|
|
} from '@/api/mail';
|
|
|
|
const PAGE_SIZE = 25;
|
|
|
|
export function MailPage() {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
|
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
|
const [selectedAccountId, setSelectedAccountId] = useState('');
|
|
const [folders, setFolders] = useState<MailFolder[]>([]);
|
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
|
const [mails, setMails] = useState<Mail[]>([]);
|
|
const [mailsTotal, setMailsTotal] = useState(0);
|
|
const [mailsPage, setMailsPage] = useState(1);
|
|
const [selectedMail, setSelectedMail] = useState<Mail | null>(null);
|
|
const [loadingMail, setLoadingMail] = useState(false);
|
|
const [loadingAccounts, setLoadingAccounts] = useState(true);
|
|
const [loadingFolders, setLoadingFolders] = useState(false);
|
|
const [loadingMails, setLoadingMails] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [signatures, setSignatures] = useState<MailSignature[]>([]);
|
|
const [composeMode, setComposeMode] = useState<ComposeMode>('new');
|
|
const [replyToMail, setReplyToMail] = useState<Mail | null>(null);
|
|
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
|
|
const [draftMailState, setDraftMailState] = useState<Mail | null>(null);
|
|
const openWindow = useWindowStore((s) => s.openWindow);
|
|
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
|
const [selectedMailIds, setSelectedMailIds] = useState<Set<string>>(new Set());
|
|
const [showMoveDropdown, setShowMoveDropdown] = useState(false);
|
|
const [sortBy, setSortBy] = useState<'date' | 'from' | 'subject'>('date');
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
|
|
|
|
// Ref to track the current folder ID for async callbacks (prevents race conditions)
|
|
const selectedFolderIdRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
selectedFolderIdRef.current = selectedFolderId;
|
|
}, [selectedFolderId]);
|
|
|
|
// Load accounts
|
|
const loadAccounts = useCallback(async () => {
|
|
setLoadingAccounts(true);
|
|
try {
|
|
const accs = await fetchAccounts();
|
|
setAccounts(accs);
|
|
if (accs.length > 0 && !selectedAccountId) {
|
|
setSelectedAccountId(accs[0].id);
|
|
}
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setError(msg);
|
|
}
|
|
setLoadingAccounts(false);
|
|
}, [selectedAccountId]);
|
|
|
|
// Load signatures
|
|
const loadSignatures = useCallback(async () => {
|
|
try {
|
|
const sigs = await fetchSignatures();
|
|
setSignatures(sigs);
|
|
} catch {
|
|
// non-critical
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadAccounts();
|
|
loadSignatures();
|
|
}, [loadAccounts, loadSignatures]);
|
|
|
|
// Load folders for ALL accounts
|
|
const loadAllFolders = useCallback(async () => {
|
|
if (accounts.length === 0) return;
|
|
setLoadingFolders(true);
|
|
try {
|
|
const allFolders: MailFolder[] = [];
|
|
for (const acc of accounts) {
|
|
const folderList = await fetchFolders(acc.id);
|
|
allFolders.push(...folderList);
|
|
}
|
|
setFolders(allFolders);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setError(msg);
|
|
}
|
|
setLoadingFolders(false);
|
|
}, [accounts]);
|
|
|
|
useEffect(() => {
|
|
loadAllFolders();
|
|
}, [loadAllFolders]);
|
|
|
|
// Auto-select first folder when folders are loaded
|
|
useEffect(() => {
|
|
if (folders.length > 0 && !selectedFolderId) {
|
|
setSelectedFolderId(folders[0].id);
|
|
setSelectedAccountId(folders[0].account_id);
|
|
}
|
|
}, [folders, selectedFolderId]);
|
|
|
|
// Load mails when folder or page changes
|
|
const loadMails = useCallback(async () => {
|
|
if (!selectedFolderId) return;
|
|
const currentFolderId = selectedFolderId;
|
|
setLoadingMails(true);
|
|
try {
|
|
if (searchQuery.trim()) {
|
|
const result = await searchMails(searchQuery);
|
|
// Only update if still on the same folder
|
|
if (selectedFolderIdRef.current === currentFolderId) {
|
|
setMails(result.mails);
|
|
setMailsTotal(result.total);
|
|
}
|
|
} else {
|
|
const result = await fetchMails(currentFolderId, mailsPage, sortBy, sortOrder);
|
|
// Only update if still on the same folder
|
|
if (selectedFolderIdRef.current === currentFolderId) {
|
|
setMails(result.mails);
|
|
setMailsTotal(result.total);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (selectedFolderIdRef.current === currentFolderId) {
|
|
setError(msg);
|
|
setMails([]);
|
|
}
|
|
}
|
|
setLoadingMails(false);
|
|
}, [selectedFolderId, mailsPage, searchQuery, sortBy, sortOrder]);
|
|
|
|
useEffect(() => {
|
|
loadMails();
|
|
}, [loadMails]);
|
|
|
|
// Handle folder selection — derive account from folder + trigger background IMAP sync
|
|
const handleSelectFolder = useCallback(
|
|
(folderId: string) => {
|
|
setSelectedFolderId(folderId);
|
|
const folder = folders.find((f) => f.id === folderId);
|
|
if (folder) {
|
|
setSelectedAccountId(folder.account_id);
|
|
}
|
|
setMailsPage(1);
|
|
setSearchQuery('');
|
|
setSelectedMail(null);
|
|
setSelectedMailIds(new Set());
|
|
setActiveView('list');
|
|
// Live sync: fetch new mails from IMAP in background, reload after
|
|
// Only reload if the user hasn't switched to another folder in the meantime
|
|
syncFolder(folderId).then(() => {
|
|
if (selectedFolderIdRef.current === folderId) {
|
|
loadMails();
|
|
loadAllFolders();
|
|
}
|
|
}).catch(() => {});
|
|
},
|
|
[folders, loadMails, loadAllFolders],
|
|
);
|
|
|
|
// Handle mail selection
|
|
const handleSelectMail = useCallback(
|
|
async (mail: Mail) => {
|
|
setActiveView('detail');
|
|
setLoadingMail(true);
|
|
try {
|
|
const detail = await getMail(mail.id);
|
|
setSelectedMail(detail);
|
|
// Mark as seen if not seen
|
|
if (!detail.is_seen) {
|
|
await updateFlags(mail.id, { is_seen: true });
|
|
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m)));
|
|
}
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
toast.error(msg);
|
|
setSelectedMail(mail);
|
|
}
|
|
setLoadingMail(false);
|
|
},
|
|
[toast],
|
|
);
|
|
|
|
// Handle send (compose)
|
|
const handleSend = useCallback(
|
|
async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => {
|
|
try {
|
|
if (mode === 'reply' && replyToMail) {
|
|
await replyMail(replyToMail.id, payload as ReplyPayload);
|
|
} else if (mode === 'forward' && forwardMailState) {
|
|
await forwardMail(forwardMailState.id, payload as ForwardPayload);
|
|
} else {
|
|
await sendMail(payload as SendMailPayload);
|
|
}
|
|
toast.success(t('mail.sent'));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
throw err;
|
|
}
|
|
},
|
|
[replyToMail, forwardMailState, toast, t],
|
|
);
|
|
|
|
// Handle save draft (new or existing)
|
|
const handleSaveDraft = useCallback(
|
|
async (payload: MailDraftPayload) => {
|
|
try {
|
|
if (composeMode === 'draft' && draftMailState) {
|
|
await updateDraft(draftMailState.id, payload);
|
|
} else {
|
|
await saveDraft(payload);
|
|
}
|
|
toast.success(t('mail.draftSaved'));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
throw err;
|
|
}
|
|
},
|
|
[composeMode, draftMailState, toast, t],
|
|
);
|
|
|
|
// Handle compose
|
|
const handleCompose = useCallback(() => {
|
|
setComposeMode('new');
|
|
setReplyToMail(null);
|
|
setForwardMailState(null);
|
|
setDraftMailState(null);
|
|
openWindow({
|
|
title: t('mail.compose'),
|
|
type: 'mail-compose',
|
|
component: MailComposeForm,
|
|
componentProps: {
|
|
mode: 'new',
|
|
accountId: selectedAccountId,
|
|
replyToMail: null,
|
|
forwardMail: null,
|
|
draftMail: null,
|
|
signatures,
|
|
onSend: handleSend,
|
|
onSaveDraft: handleSaveDraft,
|
|
},
|
|
});
|
|
}, [openWindow, t, selectedAccountId, signatures, handleSend, handleSaveDraft]);
|
|
|
|
// Handle reply
|
|
const handleReply = useCallback((mail: Mail) => {
|
|
setComposeMode('reply');
|
|
setReplyToMail(mail);
|
|
setForwardMailState(null);
|
|
setDraftMailState(null);
|
|
openWindow({
|
|
title: t('mail.reply'),
|
|
type: 'mail-compose',
|
|
component: MailComposeForm,
|
|
componentProps: {
|
|
mode: 'reply',
|
|
accountId: selectedAccountId,
|
|
replyToMail: mail,
|
|
forwardMail: null,
|
|
draftMail: null,
|
|
signatures,
|
|
onSend: handleSend,
|
|
onSaveDraft: handleSaveDraft,
|
|
},
|
|
});
|
|
}, [openWindow, t, selectedAccountId, signatures, handleSend, handleSaveDraft]);
|
|
|
|
// Handle forward
|
|
const handleForward = useCallback((mail: Mail) => {
|
|
setComposeMode('forward');
|
|
setForwardMailState(mail);
|
|
setReplyToMail(null);
|
|
setDraftMailState(null);
|
|
openWindow({
|
|
title: t('mail.forward'),
|
|
type: 'mail-compose',
|
|
component: MailComposeForm,
|
|
componentProps: {
|
|
mode: 'forward',
|
|
accountId: selectedAccountId,
|
|
replyToMail: null,
|
|
forwardMail: mail,
|
|
draftMail: null,
|
|
signatures,
|
|
onSend: handleSend,
|
|
onSaveDraft: handleSaveDraft,
|
|
},
|
|
});
|
|
}, [openWindow, t, selectedAccountId, signatures, handleSend, handleSaveDraft]);
|
|
|
|
// Handle delete single mail
|
|
const handleDeleteMail = useCallback(
|
|
async (mail: Mail) => {
|
|
if (!window.confirm(t('mail.deleteConfirm'))) return;
|
|
try {
|
|
await deleteMail(mail.id);
|
|
setMails((prev) => prev.filter((m) => m.id !== mail.id));
|
|
setSelectedMail(null);
|
|
toast.success(t('mail.delete'));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
[toast, t],
|
|
);
|
|
|
|
// Handle bulk delete
|
|
const handleBulkDelete = useCallback(async () => {
|
|
if (!window.confirm(t('mail.bulkDeleteConfirm'))) return;
|
|
const ids = Array.from(selectedMailIds);
|
|
try {
|
|
await Promise.all(ids.map((id) => deleteMail(id)));
|
|
setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id)));
|
|
setSelectedMailIds(new Set());
|
|
setSelectedMail(null);
|
|
toast.success(t('mail.delete'));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}, [selectedMailIds, toast, t]);
|
|
|
|
// Handle move single mail
|
|
const handleMoveMail = useCallback(
|
|
async (mail: Mail, targetFolderId: string) => {
|
|
try {
|
|
await moveMail(mail.id, targetFolderId);
|
|
setMails((prev) => prev.filter((m) => m.id !== mail.id));
|
|
setSelectedMail(null);
|
|
setShowMoveDropdown(false);
|
|
toast.success(t('mail.move'));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
[toast, t],
|
|
);
|
|
|
|
// Handle bulk move
|
|
const handleBulkMove = useCallback(
|
|
async (targetFolderId: string) => {
|
|
const ids = Array.from(selectedMailIds);
|
|
try {
|
|
await Promise.all(ids.map((id) => moveMail(id, targetFolderId)));
|
|
setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id)));
|
|
setSelectedMailIds(new Set());
|
|
setShowMoveDropdown(false);
|
|
toast.success(t('mail.move'));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
[selectedMailIds, toast, t],
|
|
);
|
|
|
|
|
|
// Handle edit draft
|
|
const handleEditDraft = useCallback((mail: Mail) => {
|
|
setComposeMode('draft');
|
|
setDraftMailState(mail);
|
|
setReplyToMail(null);
|
|
setForwardMailState(null);
|
|
openWindow({
|
|
title: t('mail.editDraft'),
|
|
type: 'mail-compose',
|
|
component: MailComposeForm,
|
|
componentProps: {
|
|
mode: 'draft',
|
|
accountId: selectedAccountId,
|
|
replyToMail: null,
|
|
forwardMail: null,
|
|
draftMail: mail,
|
|
signatures,
|
|
onSend: handleSend,
|
|
onSaveDraft: handleSaveDraft,
|
|
},
|
|
});
|
|
}, [openWindow, t, selectedAccountId, signatures, handleSend, handleSaveDraft]);
|
|
|
|
// Handle toggle flag
|
|
const handleToggleFlag = useCallback(
|
|
async (mail: Mail) => {
|
|
try {
|
|
await updateFlags(mail.id, { is_flagged: !mail.is_flagged });
|
|
setSelectedMail((prev) => (prev ? { ...prev, is_flagged: !mail.is_flagged } : prev));
|
|
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_flagged: !mail.is_flagged } : m)));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
[toast],
|
|
);
|
|
|
|
// Handle set flag type (symbol picker)
|
|
const handleSetFlagType = useCallback(
|
|
async (mail: Mail, flagType: string) => {
|
|
try {
|
|
const isFlagged = flagType !== '';
|
|
await updateFlags(mail.id, { is_flagged: isFlagged, flag_type: flagType || null });
|
|
setSelectedMail((prev) => (prev ? { ...prev, is_flagged: isFlagged, flag_type: flagType || null } : prev));
|
|
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_flagged: isFlagged, flag_type: flagType || null } : m)));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
[toast],
|
|
);
|
|
|
|
// Handle toggle select for bulk operations
|
|
const handleToggleSelect = useCallback((mailId: string) => {
|
|
setSelectedMailIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(mailId)) {
|
|
next.delete(mailId);
|
|
} else {
|
|
next.add(mailId);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Handle select all mails on current page
|
|
const handleSelectAll = useCallback(() => {
|
|
setSelectedMailIds((prev) => {
|
|
const allSelected = mails.length > 0 && mails.every((m) => prev.has(m.id));
|
|
if (allSelected) {
|
|
// Deselect all on current page
|
|
const next = new Set(prev);
|
|
for (const m of mails) {
|
|
next.delete(m.id);
|
|
}
|
|
return next;
|
|
}
|
|
// Select all on current page
|
|
const next = new Set(prev);
|
|
for (const m of mails) {
|
|
next.add(m.id);
|
|
}
|
|
return next;
|
|
});
|
|
}, [mails]);
|
|
|
|
// Handle bulk mark as read
|
|
const handleBulkMarkRead = useCallback(async () => {
|
|
const ids = Array.from(selectedMailIds);
|
|
try {
|
|
await Promise.all(ids.map((id) => updateFlags(id, { is_seen: true })));
|
|
setMails((prev) => prev.map((m) => (selectedMailIds.has(m.id) ? { ...m, is_seen: true } : m)));
|
|
setSelectedMailIds(new Set());
|
|
toast.success(t('mail.markRead'));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}, [selectedMailIds, toast, t]);
|
|
|
|
// Handle bulk mark as unread
|
|
const handleBulkMarkUnread = useCallback(async () => {
|
|
const ids = Array.from(selectedMailIds);
|
|
try {
|
|
await Promise.all(ids.map((id) => updateFlags(id, { is_seen: false })));
|
|
setMails((prev) => prev.map((m) => (selectedMailIds.has(m.id) ? { ...m, is_seen: false } : m)));
|
|
setSelectedMailIds(new Set());
|
|
toast.success(t('mail.markUnread'));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}, [selectedMailIds, toast, t]);
|
|
|
|
// Handle create event from mail
|
|
const handleCreateEvent = useCallback(
|
|
async (mail: Mail) => {
|
|
const title = mail.subject || t('mail.noSubject');
|
|
const now = new Date();
|
|
const start = now.toISOString();
|
|
const end = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
|
|
const payload: CreateEventFromMailPayload = {
|
|
title,
|
|
start,
|
|
end,
|
|
description: mail.body_text.slice(0, 500),
|
|
};
|
|
try {
|
|
await createEventFromMail(mail.id, payload);
|
|
toast.success(t('mail.eventCreated'));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
[toast, t],
|
|
);
|
|
|
|
// Handle attachment download
|
|
const handleDownloadAttachment = useCallback(
|
|
async (mailId: string, attachment: MailAttachment) => {
|
|
setDownloadingAttachmentId(attachment.id);
|
|
try {
|
|
const blob = await downloadAttachment(mailId, attachment.id);
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = window.document.createElement('a');
|
|
a.href = url;
|
|
a.download = attachment.filename;
|
|
window.document.body.appendChild(a);
|
|
a.click();
|
|
window.document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setDownloadingAttachmentId(null);
|
|
}
|
|
},
|
|
[toast],
|
|
);
|
|
|
|
|
|
// Handle search
|
|
const handleSearch = useCallback((query: string) => {
|
|
setSearchQuery(query);
|
|
setMailsPage(1);
|
|
}, []);
|
|
|
|
// Register toolbar items (account-select removed — account is now in folder tree)
|
|
const registerItems = usePluginToolbarStore((s) => s.registerItems);
|
|
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
|
|
const selectedMailId = selectedMail?.id;
|
|
const selectedMailFlagged = selectedMail?.is_flagged;
|
|
useEffect(() => {
|
|
const hasMail = !!selectedMailId;
|
|
const hasBulkSelection = selectedMailIds.size > 0;
|
|
const items = [
|
|
{
|
|
id: 'search',
|
|
plugin: 'mail',
|
|
label: 'Suchen',
|
|
type: 'search' as const,
|
|
group: 'search',
|
|
searchPlaceholder: 'E-Mails durchsuchen...',
|
|
onSearch: handleSearch,
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'compose',
|
|
plugin: 'mail',
|
|
label: 'Verfassen',
|
|
group: 'compose',
|
|
icon: (
|
|
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: handleCompose,
|
|
},
|
|
{
|
|
id: 'reply',
|
|
plugin: 'mail',
|
|
label: 'Antworten',
|
|
group: 'mail-actions',
|
|
disabled: !hasMail,
|
|
icon: (
|
|
<Undo2 className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: () => selectedMail && handleReply(selectedMail),
|
|
},
|
|
{
|
|
id: 'forward',
|
|
plugin: 'mail',
|
|
label: 'Weiterleiten',
|
|
group: 'mail-actions',
|
|
disabled: !hasMail,
|
|
icon: (
|
|
<Redo2 className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: () => selectedMail && handleForward(selectedMail),
|
|
},
|
|
{
|
|
id: 'flag',
|
|
plugin: 'mail',
|
|
label: 'Markierung',
|
|
group: 'mail-actions',
|
|
disabled: !hasMail,
|
|
type: 'select' as const,
|
|
selectValue: selectedMail?.flag_type || '',
|
|
selectOptions: [
|
|
{ value: '', label: 'Keine' },
|
|
{ value: 'star', label: '⭐ Stern' },
|
|
{ value: 'flag', label: '🚩 Flagge' },
|
|
{ value: 'bookmark', label: '🔖 Lesezeichen' },
|
|
{ value: 'important', label: '❗ Wichtig' },
|
|
{ value: 'question', label: '❓ Frage' },
|
|
],
|
|
onSelect: (value: string) => selectedMail && handleSetFlagType(selectedMail, value),
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'delete',
|
|
plugin: 'mail',
|
|
label: t('mail.delete'),
|
|
group: 'mail-actions',
|
|
disabled: !hasMail,
|
|
icon: (
|
|
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: () => selectedMail && handleDeleteMail(selectedMail),
|
|
},
|
|
{
|
|
id: 'move',
|
|
plugin: 'mail',
|
|
label: t('mail.move'),
|
|
group: 'mail-actions',
|
|
disabled: !hasMail,
|
|
icon: (
|
|
<ArrowRight className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: () => setShowMoveDropdown((v) => !v),
|
|
},
|
|
{
|
|
id: 'sync',
|
|
plugin: 'mail',
|
|
label: isSyncing ? t('mail.syncing') : 'Sync',
|
|
group: 'tools',
|
|
disabled: isSyncing || !selectedAccountId,
|
|
icon: (
|
|
<Loader2 className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`} strokeWidth={2} />
|
|
),
|
|
onClick: async () => {
|
|
if (!selectedAccountId) return;
|
|
setIsSyncing(true);
|
|
try {
|
|
const result = await triggerSync(selectedAccountId);
|
|
if ((result as any)?.error) {
|
|
toast.error((result as any).error);
|
|
} else {
|
|
await loadMails();
|
|
toast.success(t('mail.syncSuccess'));
|
|
}
|
|
} catch (err: any) {
|
|
const msg = err?.message || err?.detail || (typeof err === 'string' ? err : 'Sync failed');
|
|
toast.error(msg);
|
|
} finally {
|
|
setIsSyncing(false);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: 'open-standalone',
|
|
plugin: 'mail',
|
|
label: 'In neuem Fenster',
|
|
type: 'button' as const,
|
|
group: 'tools',
|
|
icon: (
|
|
<ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: () => window.open('/mail-standalone', '_blank', 'width=1200,height=800'),
|
|
},
|
|
];
|
|
if (hasBulkSelection) {
|
|
items.push(
|
|
{
|
|
id: 'bulk-mark-read',
|
|
plugin: 'mail',
|
|
label: t('mail.markRead'),
|
|
group: 'bulk-actions',
|
|
icon: (
|
|
<Check className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: handleBulkMarkRead,
|
|
},
|
|
{
|
|
id: 'bulk-mark-unread',
|
|
plugin: 'mail',
|
|
label: t('mail.markUnread'),
|
|
group: 'bulk-actions',
|
|
icon: (
|
|
<TrendingUp className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: handleBulkMarkUnread,
|
|
},
|
|
{
|
|
id: 'bulk-delete',
|
|
plugin: 'mail',
|
|
label: t('mail.bulkDelete'),
|
|
group: 'bulk-actions',
|
|
icon: (
|
|
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: handleBulkDelete,
|
|
},
|
|
{
|
|
id: 'bulk-move',
|
|
plugin: 'mail',
|
|
label: t('mail.bulkMove'),
|
|
group: 'bulk-actions',
|
|
icon: (
|
|
<ArrowRight className="w-3.5 h-3.5" strokeWidth={2} />
|
|
),
|
|
onClick: () => setShowMoveDropdown((v) => !v),
|
|
},
|
|
);
|
|
}
|
|
registerItems('mail', items);
|
|
return () => unregisterPlugin('mail');
|
|
}, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleSetFlagType, handleDeleteMail, handleBulkDelete, handleBulkMarkRead, handleBulkMarkUnread, t]);
|
|
|
|
if (loadingAccounts) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12" data-testid="mail-page-loading">
|
|
<Loader2 className="animate-spin h-6 w-6 text-secondary-400" aria-hidden="true" />
|
|
<span className="ml-2 text-secondary-500">{t('common.loading')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (accounts.length === 0) {
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto" data-testid="mail-page">
|
|
<EmptyState
|
|
title={t('mail.noAccounts')}
|
|
description={t('mail.noAccountsDesc')}
|
|
action={<Link to="/settings/mail" className="text-primary-600 hover:text-primary-700">{t('mail.configureAccount')}</Link>}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full" data-testid="mail-page">
|
|
{error && (
|
|
<div className="mb-2 p-3 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="mail-error">
|
|
<p className="text-sm text-danger-700">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop: three-pane layout with resizable panels */}
|
|
<div className="hidden md:flex flex-1 overflow-hidden">
|
|
{/* Folder tree — resizable */}
|
|
<ResizablePanel
|
|
initialWidth={224}
|
|
minWidth={150}
|
|
maxWidth={400}
|
|
className="border-r border-secondary-200 bg-white"
|
|
data-testid="mail-folder-pane"
|
|
>
|
|
<div className="p-3">
|
|
<MailFolderTree
|
|
accounts={accounts}
|
|
folders={folders}
|
|
selectedFolderId={selectedFolderId}
|
|
onSelect={handleSelectFolder}
|
|
loading={loadingFolders}
|
|
onFolderEmptied={() => { loadMails(); loadAllFolders(); }}
|
|
/>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
{/* Mail list — resizable */}
|
|
<ResizablePanel
|
|
initialWidth={320}
|
|
minWidth={200}
|
|
maxWidth={500}
|
|
className="border-r border-secondary-200 bg-white"
|
|
data-testid="mail-list-pane"
|
|
>
|
|
{/* Saved filter bar + tag selector */}
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 bg-secondary-50 flex-wrap" data-testid="mail-filter-bar">
|
|
<SavedFilterBar
|
|
entityType="mail"
|
|
currentFilters={{ search: searchQuery, sortBy, sortOrder, folderId: selectedFolderId }}
|
|
onApplyFilter={(criteria) => {
|
|
if (criteria.search !== undefined) handleSearch(criteria.search);
|
|
if (criteria.sortBy) setSortBy(criteria.sortBy as 'date' | 'from' | 'subject');
|
|
if (criteria.sortOrder) setSortOrder(criteria.sortOrder as 'asc' | 'desc');
|
|
}}
|
|
/>
|
|
<div className="flex-1 min-w-[180px]" data-testid="mail-tag-selector">
|
|
<TagSelector
|
|
entityType="file"
|
|
entityId={selectedMail?.id ?? ''}
|
|
selectedTags={selectedTags}
|
|
onChange={setSelectedTags}
|
|
placeholder={t('tags.selectPlaceholder', 'Tags filtern...')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<MailList
|
|
mails={mails}
|
|
selectedMailId={selectedMail?.id || null}
|
|
onSelectMail={handleSelectMail}
|
|
loading={loadingMails}
|
|
currentPage={mailsPage}
|
|
total={mailsTotal}
|
|
pageSize={PAGE_SIZE}
|
|
onPageChange={setMailsPage}
|
|
selectedMailIds={selectedMailIds}
|
|
onToggleSelect={handleToggleSelect}
|
|
onSelectAll={handleSelectAll}
|
|
sortBy={sortBy}
|
|
sortOrder={sortOrder}
|
|
onSortChange={(by, order) => {
|
|
setSortBy(by);
|
|
setSortOrder(order);
|
|
setMailsPage(1);
|
|
}}
|
|
/>
|
|
</ResizablePanel>
|
|
|
|
{/* Reading pane — flex-1, not resizable */}
|
|
<ResizablePanel
|
|
resizable={false}
|
|
className="bg-white"
|
|
data-testid="mail-detail-pane"
|
|
>
|
|
<MailDetail
|
|
mail={selectedMail}
|
|
loading={loadingMail}
|
|
onReply={handleReply}
|
|
onForward={handleForward}
|
|
onCreateEvent={handleCreateEvent}
|
|
onToggleFlag={handleToggleFlag}
|
|
onDownloadAttachment={handleDownloadAttachment}
|
|
downloadingAttachmentId={downloadingAttachmentId}
|
|
onDelete={handleDeleteMail}
|
|
onMove={(mail) => setShowMoveDropdown(true)}
|
|
onEditDraft={handleEditDraft}
|
|
/>
|
|
</ResizablePanel>
|
|
</div>
|
|
|
|
{/* Mobile: single-pane view switching */}
|
|
<div className="flex md:hidden flex-1 overflow-hidden flex-col">
|
|
{/* View 1: Folder tree */}
|
|
{activeView === 'folders' && (
|
|
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-folder-pane">
|
|
<div className="p-3">
|
|
<MailFolderTree
|
|
accounts={accounts}
|
|
folders={folders}
|
|
selectedFolderId={selectedFolderId}
|
|
onSelect={handleSelectFolder}
|
|
loading={loadingFolders}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* View 2: Mail list */}
|
|
{activeView === 'list' && (
|
|
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-list-pane">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
|
<button
|
|
onClick={() => setActiveView('folders')}
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
|
aria-label="Zurück zu Ordnern"
|
|
data-testid="mobile-back-to-folders"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
|
<span className="text-sm font-medium">Ordner</span>
|
|
</button>
|
|
</div>
|
|
<MailList
|
|
mails={mails}
|
|
selectedMailId={selectedMail?.id || null}
|
|
onSelectMail={handleSelectMail}
|
|
loading={loadingMails}
|
|
currentPage={mailsPage}
|
|
total={mailsTotal}
|
|
pageSize={PAGE_SIZE}
|
|
onPageChange={setMailsPage}
|
|
selectedMailIds={selectedMailIds}
|
|
onToggleSelect={handleToggleSelect}
|
|
onSelectAll={handleSelectAll}
|
|
sortBy={sortBy}
|
|
sortOrder={sortOrder}
|
|
onSortChange={(by, order) => {
|
|
setSortBy(by);
|
|
setSortOrder(order);
|
|
setMailsPage(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* View 3: Mail detail */}
|
|
{activeView === 'detail' && (
|
|
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-detail-pane">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
|
<button
|
|
onClick={() => setActiveView('list')}
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
|
aria-label="Zurück zur Liste"
|
|
data-testid="mobile-back-to-list"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
|
<span className="text-sm font-medium">Zurück</span>
|
|
</button>
|
|
</div>
|
|
<MailDetail
|
|
mail={selectedMail}
|
|
loading={loadingMail}
|
|
onReply={handleReply}
|
|
onForward={handleForward}
|
|
onCreateEvent={handleCreateEvent}
|
|
onToggleFlag={handleToggleFlag}
|
|
onDownloadAttachment={handleDownloadAttachment}
|
|
downloadingAttachmentId={downloadingAttachmentId}
|
|
onDelete={handleDeleteMail}
|
|
onMove={(mail) => setShowMoveDropdown(true)}
|
|
onEditDraft={handleEditDraft}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Floating bulk action bar */}
|
|
{selectedMailIds.size > 0 && (
|
|
<div
|
|
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 px-4 py-2 bg-white border border-secondary-200 rounded-lg shadow-lg"
|
|
data-testid="mail-bulk-action-bar"
|
|
>
|
|
<span className="text-sm text-secondary-600">
|
|
{t('mail.selectedCount', { count: selectedMailIds.size })}
|
|
</span>
|
|
<div className="w-px h-5 bg-secondary-200" />
|
|
<Button variant="secondary" size="sm" onClick={handleBulkMarkRead}>
|
|
{t('mail.markRead')}
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={handleBulkMarkUnread}>
|
|
{t('mail.markUnread')}
|
|
</Button>
|
|
<Button variant="danger" size="sm" onClick={handleBulkDelete}>
|
|
{t('mail.bulkDelete')}
|
|
</Button>
|
|
<div className="relative">
|
|
<Button variant="secondary" size="sm" onClick={() => setShowMoveDropdown((v) => !v)}>
|
|
{t('mail.bulkMove')}
|
|
</Button>
|
|
{showMoveDropdown && (
|
|
<div className="absolute bottom-full mb-2 left-0 bg-white border border-secondary-200 rounded-lg shadow-lg max-h-60 overflow-y-auto min-w-48">
|
|
{folders.map((folder) => (
|
|
<button
|
|
key={folder.id}
|
|
onClick={() => handleBulkMove(folder.id)}
|
|
className="block w-full text-left px-3 py-2 text-sm hover:bg-secondary-100 min-h-touch"
|
|
>
|
|
{folder.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={() => setSelectedMailIds(new Set())}>
|
|
✕
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Move dropdown for single mail */}
|
|
{showMoveDropdown && selectedMail && selectedMailIds.size === 0 && (
|
|
<div
|
|
className="fixed bottom-4 right-4 z-50 bg-white border border-secondary-200 rounded-lg shadow-lg max-h-60 overflow-y-auto min-w-48"
|
|
data-testid="mail-move-dropdown"
|
|
>
|
|
<div className="px-3 py-2 text-xs font-medium text-secondary-500 border-b border-secondary-100">
|
|
{t('mail.moveTo')}
|
|
</div>
|
|
{folders
|
|
.filter((f) => f.account_id === selectedMail.account_id)
|
|
.map((folder) => (
|
|
<button
|
|
key={folder.id}
|
|
onClick={() => handleMoveMail(selectedMail, folder.id)}
|
|
className="block w-full text-left px-3 py-2 text-sm hover:bg-secondary-100 min-h-touch"
|
|
>
|
|
{folder.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|