0070fb3aea
- Mail page: 3-pane layout (folder tree + mail list + reading pane) - Compose modal: rich text editor (bold/italic/link), template picker, reply/forward pre-fill - Mail settings: accounts, signatures, rules, labels, vacation, PGP (6 tabs) - Shared mailbox selector: switch between personal + shared accounts - Mail search bar + attachment download + create-event-from-mail - Global search: tabs for companies/contacts/mails/files/events - Search autocomplete in TopBar (existing SearchDropdown) - API client: mail.ts (all endpoints) - Routes: /mail, /mail/settings - i18n: de.json + en.json mail + search translations - 44 new tests (4 test files), full regression 318/318 pass - tsc --noEmit: 0 errors, vite build: 267 modules
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
/**
|
|
* Mail page — folder tree + mail list + reading pane + compose.
|
|
*/
|
|
|
|
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 { 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 {
|
|
fetchAccounts,
|
|
fetchFolders,
|
|
fetchMails,
|
|
getMail,
|
|
sendMail,
|
|
replyMail,
|
|
forwardMail,
|
|
updateFlags,
|
|
createEventFromMail,
|
|
downloadAttachment,
|
|
fetchSignatures,
|
|
searchMails,
|
|
type MailAccount,
|
|
type MailFolder,
|
|
type Mail,
|
|
type MailSignature,
|
|
type MailAttachment,
|
|
type SendMailPayload,
|
|
type ReplyPayload,
|
|
type ForwardPayload,
|
|
type CreateEventFromMailPayload,
|
|
} 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 [composeOpen, setComposeOpen] = useState(false);
|
|
const [composeMode, setComposeMode] = useState<ComposeMode>('new');
|
|
const [replyToMail, setReplyToMail] = useState<Mail | null>(null);
|
|
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
|
|
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
// 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 when account changes
|
|
const loadFolders = useCallback(async () => {
|
|
if (!selectedAccountId) return;
|
|
setLoadingFolders(true);
|
|
try {
|
|
const folderList = await fetchFolders(selectedAccountId);
|
|
setFolders(folderList);
|
|
// Auto-select first folder
|
|
if (folderList.length > 0 && !selectedFolderId) {
|
|
setSelectedFolderId(folderList[0].id);
|
|
}
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setError(msg);
|
|
}
|
|
setLoadingFolders(false);
|
|
}, [selectedAccountId, selectedFolderId]);
|
|
|
|
useEffect(() => {
|
|
loadFolders();
|
|
}, [loadFolders]);
|
|
|
|
// 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);
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
loadMails();
|
|
}, [loadMails]);
|
|
|
|
// Handle folder selection
|
|
const handleSelectFolder = useCallback((folderId: string) => {
|
|
setSelectedFolderId(folderId);
|
|
setMailsPage(1);
|
|
setSearchQuery('');
|
|
setSelectedMail(null);
|
|
}, []);
|
|
|
|
// Handle mail selection
|
|
const handleSelectMail = useCallback(async (mail: Mail) => {
|
|
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, { 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);
|
|
setComposeOpen(true);
|
|
}, []);
|
|
|
|
// Handle reply
|
|
const handleReply = useCallback((mail: Mail) => {
|
|
setComposeMode('reply');
|
|
setReplyToMail(mail);
|
|
setForwardMailState(null);
|
|
setComposeOpen(true);
|
|
}, []);
|
|
|
|
// Handle forward
|
|
const handleForward = useCallback((mail: Mail) => {
|
|
setComposeMode('forward');
|
|
setForwardMailState(mail);
|
|
setReplyToMail(null);
|
|
setComposeOpen(true);
|
|
}, []);
|
|
|
|
// Handle toggle flag
|
|
const handleToggleFlag = useCallback(async (mail: Mail) => {
|
|
try {
|
|
await updateFlags(mail.id, { 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 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);
|
|
}, []);
|
|
|
|
// Handle account switch
|
|
const handleAccountSwitch = useCallback((accountId: string) => {
|
|
setSelectedAccountId(accountId);
|
|
setSelectedFolderId(null);
|
|
setSelectedMail(null);
|
|
setMails([]);
|
|
setMailsPage(1);
|
|
setSearchQuery('');
|
|
}, []);
|
|
|
|
if (loadingAccounts) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12" data-testid="mail-page-loading">
|
|
<svg className="animate-spin h-6 w-6 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<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="/mail/settings" 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">
|
|
{/* Top bar */}
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-secondary-200 bg-white">
|
|
<SharedMailboxSelector
|
|
accounts={accounts}
|
|
selectedAccountId={selectedAccountId}
|
|
onSelect={handleAccountSwitch}
|
|
/>
|
|
<div className="flex-1 max-w-md">
|
|
<MailSearchBar onSearch={handleSearch} />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={handleCompose} data-testid="compose-btn">
|
|
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
{t('mail.compose')}
|
|
</Button>
|
|
<Link to="/mail/settings" className="text-sm text-primary-600 hover:text-primary-700">
|
|
{t('mail.settings')}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{/* Three-pane layout */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Folder tree */}
|
|
<div className="w-56 border-r border-secondary-200 overflow-y-auto bg-white" data-testid="mail-folder-pane">
|
|
<div className="p-3">
|
|
<h3 className="text-xs font-semibold text-secondary-500 uppercase mb-2">{t('mail.folders')}</h3>
|
|
<MailFolderTree
|
|
folders={folders}
|
|
selectedFolderId={selectedFolderId}
|
|
onSelect={handleSelectFolder}
|
|
loading={loadingFolders}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mail list */}
|
|
<div className="w-80 border-r border-secondary-200 overflow-y-auto bg-white" data-testid="mail-list-pane">
|
|
<MailList
|
|
mails={mails}
|
|
selectedMailId={selectedMail?.id || null}
|
|
onSelectMail={handleSelectMail}
|
|
loading={loadingMails}
|
|
currentPage={mailsPage}
|
|
total={mailsTotal}
|
|
pageSize={PAGE_SIZE}
|
|
onPageChange={setMailsPage}
|
|
/>
|
|
</div>
|
|
|
|
{/* Reading pane */}
|
|
<div className="flex-1 overflow-y-auto bg-white" data-testid="mail-detail-pane">
|
|
<MailDetail
|
|
mail={selectedMail}
|
|
loading={loadingMail}
|
|
onReply={handleReply}
|
|
onForward={handleForward}
|
|
onCreateEvent={handleCreateEvent}
|
|
onToggleFlag={handleToggleFlag}
|
|
onDownloadAttachment={handleDownloadAttachment}
|
|
downloadingAttachmentId={downloadingAttachmentId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<ComposeModal
|
|
open={composeOpen}
|
|
mode={composeMode}
|
|
accountId={selectedAccountId}
|
|
replyToMail={replyToMail}
|
|
forwardMail={forwardMailState}
|
|
signatures={signatures}
|
|
onSend={handleSend}
|
|
onClose={() => setComposeOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
} |