/** * 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 } 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 { ComposeModal, type ComposeMode } from '@/components/mail/ComposeModal'; import { SharedMailboxSelector } from '@/components/mail/SharedMailboxSelector'; import { MailSearchBar } from '@/components/mail/MailSearchBar'; import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; import { fetchAccounts, fetchFolders, fetchMails, getMail, sendMail, replyMail, forwardMail, updateFlags, createEventFromMail, downloadAttachment, fetchSignatures, searchMails, deleteMail, moveMail, saveDraft, updateDraft, 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([]); const [selectedAccountId, setSelectedAccountId] = useState(''); const [folders, setFolders] = useState([]); const [selectedFolderId, setSelectedFolderId] = useState(null); const [mails, setMails] = useState([]); const [mailsTotal, setMailsTotal] = useState(0); const [mailsPage, setMailsPage] = useState(1); const [selectedMail, setSelectedMail] = useState(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(null); const [signatures, setSignatures] = useState([]); const [composeOpen, setComposeOpen] = useState(false); const [composeMode, setComposeMode] = useState('new'); const [replyToMail, setReplyToMail] = useState(null); const [forwardMailState, setForwardMailState] = useState(null); const [draftMailState, setDraftMailState] = useState(null); const [downloadingAttachmentId, setDownloadingAttachmentId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders'); const [selectedMailIds, setSelectedMailIds] = useState>(new Set()); const [showMoveDropdown, setShowMoveDropdown] = useState(false); const [sortBy, setSortBy] = useState<'date' | 'from' | 'subject'>('date'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); // 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; setLoadingMails(true); try { if (searchQuery.trim()) { const result = await searchMails(searchQuery); setMails(result.mails); setMailsTotal(result.total); } else { const result = await fetchMails(selectedFolderId, mailsPage, sortBy, sortOrder); setMails(result.mails); setMailsTotal(result.total); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); setError(msg); setMails([]); } setLoadingMails(false); }, [selectedFolderId, mailsPage, searchQuery, sortBy, sortOrder]); useEffect(() => { loadMails(); }, [loadMails]); // Handle folder selection — derive account from folder 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'); }, [folders], ); // 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 compose const handleCompose = useCallback(() => { setComposeMode('new'); setReplyToMail(null); setForwardMailState(null); setDraftMailState(null); setComposeOpen(true); }, []); // Handle reply const handleReply = useCallback((mail: Mail) => { setComposeMode('reply'); setReplyToMail(mail); setForwardMailState(null); setDraftMailState(null); setComposeOpen(true); }, []); // Handle forward const handleForward = useCallback((mail: Mail) => { setComposeMode('forward'); setForwardMailState(mail); setReplyToMail(null); setDraftMailState(null); setComposeOpen(true); }, []); // 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 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 edit draft const handleEditDraft = useCallback((mail: Mail) => { setComposeMode('draft'); setDraftMailState(mail); setReplyToMail(null); setForwardMailState(null); setComposeOpen(true); }, []); // 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 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 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 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: ( ), onClick: handleCompose, }, { id: 'reply', plugin: 'mail', label: 'Antworten', group: 'mail-actions', disabled: !hasMail, icon: ( ), onClick: () => selectedMail && handleReply(selectedMail), }, { id: 'forward', plugin: 'mail', label: 'Weiterleiten', group: 'mail-actions', disabled: !hasMail, icon: ( ), onClick: () => selectedMail && handleForward(selectedMail), }, { id: 'flag', plugin: 'mail', label: 'Flagge', group: 'mail-actions', disabled: !hasMail, active: selectedMailFlagged, icon: ( ), onClick: () => selectedMail && handleToggleFlag(selectedMail), }, { id: 'create-event', plugin: 'mail', label: 'Termin', group: 'mail-actions', disabled: !hasMail, icon: ( ), onClick: () => selectedMail && handleCreateEvent(selectedMail), }, { id: 'delete', plugin: 'mail', label: t('mail.delete'), group: 'mail-actions', disabled: !hasMail, icon: ( ), onClick: () => selectedMail && handleDeleteMail(selectedMail), }, { id: 'move', plugin: 'mail', label: t('mail.move'), group: 'mail-actions', disabled: !hasMail, icon: ( ), onClick: () => setShowMoveDropdown((v) => !v), }, { id: 'sync', plugin: 'mail', label: 'Sync', group: 'tools', icon: ( ), onClick: () => { /* sync trigger */ }, }, ]; if (hasBulkSelection) { items.push( { id: 'bulk-mark-read', plugin: 'mail', label: t('mail.markRead'), group: 'bulk-actions', icon: ( ), onClick: handleBulkMarkRead, }, { id: 'bulk-mark-unread', plugin: 'mail', label: t('mail.markUnread'), group: 'bulk-actions', icon: ( ), onClick: handleBulkMarkUnread, }, { id: 'bulk-delete', plugin: 'mail', label: t('mail.bulkDelete'), group: 'bulk-actions', icon: ( ), onClick: handleBulkDelete, }, { id: 'bulk-move', plugin: 'mail', label: t('mail.bulkMove'), group: 'bulk-actions', icon: ( ), onClick: () => setShowMoveDropdown((v) => !v), }, ); } registerItems('mail', items); return () => unregisterPlugin('mail'); }, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleDeleteMail, handleBulkDelete, handleBulkMarkRead, handleBulkMarkUnread, t]); if (loadingAccounts) { return (
{t('common.loading')}
); } if (accounts.length === 0) { return (
{t('mail.configureAccount')}} />
); } return (
{error && (

{error}

)} {/* Desktop: three-pane layout with resizable panels */}
{/* Folder tree — resizable */}
{/* Mail list — resizable */} { setSortBy(by); setSortOrder(order); setMailsPage(1); }} /> {/* Reading pane — flex-1, not resizable */} setShowMoveDropdown(true)} onEditDraft={handleEditDraft} />
{/* Mobile: single-pane view switching */}
{/* View 1: Folder tree */} {activeView === 'folders' && (
)} {/* View 2: Mail list */} {activeView === 'list' && (
{ setSortBy(by); setSortOrder(order); setMailsPage(1); }} />
)} {/* View 3: Mail detail */} {activeView === 'detail' && (
setShowMoveDropdown(true)} onEditDraft={handleEditDraft} />
)}
{/* Floating bulk action bar */} {selectedMailIds.size > 0 && (
{t('mail.selectedCount', { count: selectedMailIds.size })}
{showMoveDropdown && (
{folders.map((folder) => ( ))}
)}
)} {/* Move dropdown for single mail */} {showMoveDropdown && selectedMail && selectedMailIds.size === 0 && (
{t('mail.moveTo')}
{folders .filter((f) => f.account_id === selectedMail.account_id) .map((folder) => ( ))}
)} setComposeOpen(false)} />
); }