T08c: Frontend Mail UI + Global Search UI — 44 tests, tsc clean, vite build pass
- 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
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* Global search results page with tabs for companies/contacts/mails/files/events.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGlobalSearch, SearchResult } from '@/api/hooks';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Tabs } from '@/components/shared/Tabs';
|
||||
|
||||
function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
@@ -25,6 +29,44 @@ function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
company: 'search.companies',
|
||||
contact: 'search.contacts',
|
||||
mail: 'search.mails',
|
||||
file: 'search.files',
|
||||
event: 'search.events',
|
||||
};
|
||||
|
||||
function renderResultIcon(type: string): React.ReactNode {
|
||||
const iconClass = 'w-10 h-10 rounded-lg flex items-center justify-center font-semibold flex-shrink-0';
|
||||
switch (type) {
|
||||
case 'company':
|
||||
return <div className={`${iconClass} bg-primary-100 text-primary-700`} aria-hidden="true">F</div>;
|
||||
case 'contact':
|
||||
return <div className={`${iconClass} bg-accent-100 text-accent-700`} aria-hidden="true">K</div>;
|
||||
case 'mail':
|
||||
return <div className={`${iconClass} bg-success-100 text-success-700`} aria-hidden="true">@</div>;
|
||||
case 'file':
|
||||
return <div className={`${iconClass} bg-warning-100 text-warning-700`} aria-hidden="true">📄</div>;
|
||||
case 'event':
|
||||
return <div className={`${iconClass} bg-secondary-100 text-secondary-700`} aria-hidden="true">📅</div>;
|
||||
default:
|
||||
return <div className={`${iconClass} bg-secondary-100 text-secondary-700`} aria-hidden="true">?</div>;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResultBadge(type: string, t: (s: string) => string): React.ReactNode {
|
||||
const labelKey = TYPE_LABELS[type] || 'search.allTypes';
|
||||
const variantMap: Record<string, 'primary' | 'info' | 'success' | 'warning' | 'default'> = {
|
||||
company: 'primary',
|
||||
contact: 'info',
|
||||
mail: 'success',
|
||||
file: 'warning',
|
||||
event: 'default',
|
||||
};
|
||||
return <Badge variant={variantMap[type] || 'default'}>{t(labelKey)}</Badge>;
|
||||
}
|
||||
|
||||
export function GlobalSearchResultsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -32,45 +74,123 @@ export function GlobalSearchResultsPage() {
|
||||
|
||||
const query = searchParams.get('q') || '';
|
||||
const [searchInput, setSearchInput] = useState(query);
|
||||
const [entityType, setEntityType] = useState(searchParams.get('type') || 'all');
|
||||
const [dateFrom, setDateFrom] = useState(searchParams.get('dateFrom') || '');
|
||||
const [dateTo, setDateTo] = useState(searchParams.get('dateTo') || '');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
const entityTypes = useMemo(() => {
|
||||
if (entityType === 'all') return undefined;
|
||||
return [entityType];
|
||||
}, [entityType]);
|
||||
|
||||
const { data: results, isLoading } = useGlobalSearch(query, entityTypes);
|
||||
const { data: results, isLoading } = useGlobalSearch(query);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params: Record<string, string> = {};
|
||||
if (searchInput) params.q = searchInput;
|
||||
if (entityType !== 'all') params.type = entityType;
|
||||
if (dateFrom) params.dateFrom = dateFrom;
|
||||
if (dateTo) params.dateTo = dateTo;
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const filteredResults = useMemo(() => {
|
||||
if (!results) return [];
|
||||
let filtered = results;
|
||||
if (dateFrom) {
|
||||
filtered = filtered.filter((r) => {
|
||||
return true;
|
||||
});
|
||||
const groupedResults = useMemo(() => {
|
||||
const groups: Record<string, SearchResult[]> = { company: [], contact: [], mail: [], file: [], event: [] };
|
||||
if (results) {
|
||||
for (const r of results) {
|
||||
if (groups[r.type]) {
|
||||
groups[r.type].push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}, [results, dateFrom, dateTo]);
|
||||
return groups;
|
||||
}, [results]);
|
||||
|
||||
const totalResults = useMemo(() => {
|
||||
if (!results) return 0;
|
||||
return results.length;
|
||||
}, [results]);
|
||||
|
||||
const renderResults = (items: SearchResult[]) => {
|
||||
if (items.length === 0) {
|
||||
return <EmptyState title={t('search.noResults', { query })} />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3" data-testid="search-results-list">
|
||||
{items.map((result) => (
|
||||
<Card key={`${result.type}-${result.id}`}>
|
||||
<button
|
||||
onClick={() => navigate(result.url)}
|
||||
className="w-full flex items-center gap-4 text-left hover:bg-secondary-50 p-2 rounded-md min-h-touch"
|
||||
data-testid={`search-result-${result.type}-${result.id}`}
|
||||
>
|
||||
{renderResultIcon(result.type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-secondary-900">
|
||||
{highlightMatch(result.name, query)}
|
||||
</p>
|
||||
{renderResultBadge(result.type, t)}
|
||||
</div>
|
||||
{result.description && (
|
||||
<p className="text-sm text-secondary-500 truncate">
|
||||
{highlightMatch(result.description, query)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-secondary-400 text-sm" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs = useMemo(() => [
|
||||
{
|
||||
key: 'all',
|
||||
label: t('search.allTypes'),
|
||||
badge: totalResults,
|
||||
content: (
|
||||
<div className="space-y-3" data-testid="search-results-all">
|
||||
{totalResults === 0 && !isLoading ? (
|
||||
<EmptyState title={t('search.noResults', { query })} />
|
||||
) : (
|
||||
renderResults(results || [])
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: t('search.companies'),
|
||||
badge: groupedResults.company.length,
|
||||
content: <div data-testid="search-results-company">{renderResults(groupedResults.company)}</div>,
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
label: t('search.contacts'),
|
||||
badge: groupedResults.contact.length,
|
||||
content: <div data-testid="search-results-contact">{renderResults(groupedResults.contact)}</div>,
|
||||
},
|
||||
{
|
||||
key: 'mail',
|
||||
label: t('search.mails'),
|
||||
badge: groupedResults.mail.length,
|
||||
content: <div data-testid="search-results-mail">{renderResults(groupedResults.mail)}</div>,
|
||||
},
|
||||
{
|
||||
key: 'file',
|
||||
label: t('search.files'),
|
||||
badge: groupedResults.file.length,
|
||||
content: <div data-testid="search-results-file">{renderResults(groupedResults.file)}</div>,
|
||||
},
|
||||
{
|
||||
key: 'event',
|
||||
label: t('search.events'),
|
||||
badge: groupedResults.event.length,
|
||||
content: <div data-testid="search-results-event">{renderResults(groupedResults.event)}</div>,
|
||||
},
|
||||
], [t, totalResults, groupedResults, results, isLoading, query, navigate]);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="global-search-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('search.title')}</h1>
|
||||
|
||||
<Card title={t('search.filters')} className="mb-6">
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<form onSubmit={handleSearch} className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label={t('common.search')}
|
||||
value={searchInput}
|
||||
@@ -78,30 +198,8 @@ export function GlobalSearchResultsPage() {
|
||||
placeholder={t('common.search')}
|
||||
data-testid="search-input"
|
||||
/>
|
||||
<Select
|
||||
label={t('search.entityType')}
|
||||
options={[
|
||||
{ value: 'all', label: t('search.allTypes') },
|
||||
{ value: 'company', label: t('search.companies') },
|
||||
{ value: 'contact', label: t('search.contacts') },
|
||||
]}
|
||||
value={entityType}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('search.dateFrom')}
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('search.dateTo')}
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" data-testid="search-submit-btn">{t('common.search')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -121,45 +219,11 @@ export function GlobalSearchResultsPage() {
|
||||
<Skeleton key={i} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredResults.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('search.noResults', { query })}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3" data-testid="search-results-list">
|
||||
{filteredResults.map((result) => (
|
||||
<Card key={`${result.type}-${result.id}`}>
|
||||
<button
|
||||
onClick={() => navigate(result.url)}
|
||||
className="w-full flex items-center gap-4 text-left hover:bg-secondary-50 p-2 rounded-md min-h-touch"
|
||||
data-testid={`search-result-${result.type}-${result.id}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center font-semibold flex-shrink-0 ${
|
||||
result.type === 'company' ? 'bg-primary-100 text-primary-700' : 'bg-accent-100 text-accent-700'
|
||||
}`} aria-hidden="true">
|
||||
{result.type === 'company' ? 'F' : 'K'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-secondary-900">
|
||||
{highlightMatch(result.name, query)}
|
||||
</p>
|
||||
<Badge variant={result.type === 'company' ? 'primary' : 'info'}>
|
||||
{result.type === 'company' ? t('search.companies') : t('search.contacts')}
|
||||
</Badge>
|
||||
</div>
|
||||
{result.description && (
|
||||
<p className="text-sm text-secondary-500 truncate">
|
||||
{highlightMatch(result.description, query)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-secondary-400 text-sm" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
<div data-testid="search-tabs">
|
||||
<Tabs tabs={tabs} defaultKey={activeTab} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Mail settings page — signatures, rules, labels, PGP, vacation responder.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Tabs } from '@/components/shared/Tabs';
|
||||
import { SignatureManager } from '@/components/mail/SignatureManager';
|
||||
import { RuleEditor } from '@/components/mail/RuleEditor';
|
||||
import { LabelManager } from '@/components/mail/LabelManager';
|
||||
import { VacationResponder } from '@/components/mail/VacationResponder';
|
||||
import { PgpSettings } from '@/components/mail/PgpSettings';
|
||||
import {
|
||||
fetchAccounts,
|
||||
createAccount,
|
||||
testConnection,
|
||||
triggerSync,
|
||||
type MailAccount,
|
||||
type CreateAccountPayload,
|
||||
} from '@/api/mail';
|
||||
|
||||
export function MailSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState('');
|
||||
const [showAddAccount, setShowAddAccount] = useState(false);
|
||||
const [newAccount, setNewAccount] = useState<CreateAccountPayload>({
|
||||
email: '',
|
||||
display_name: '',
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
password: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState<string | null>(null);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accs = await fetchAccounts();
|
||||
setAccounts(accs);
|
||||
if (accs.length > 0 && !selectedAccountId) {
|
||||
setSelectedAccountId(accs[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setLoading(false);
|
||||
}, [selectedAccountId, toast]);
|
||||
|
||||
useEffect(() => { loadAccounts(); }, [loadAccounts]);
|
||||
|
||||
const handleCreateAccount = useCallback(async () => {
|
||||
if (!newAccount.email.trim() || !newAccount.password.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const acc = await createAccount(newAccount);
|
||||
setAccounts((prev) => [...prev, acc]);
|
||||
toast.success(t('mail.accountCreated'));
|
||||
setShowAddAccount(false);
|
||||
setNewAccount({
|
||||
email: '',
|
||||
display_name: '',
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
password: '',
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [newAccount, toast, t]);
|
||||
|
||||
const handleTestConnection = useCallback(async (accountId: string) => {
|
||||
setTesting(accountId);
|
||||
try {
|
||||
const result = await testConnection(accountId);
|
||||
if (result.success) {
|
||||
toast.success(t('mail.connectionSuccess'));
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
}, [toast, t]);
|
||||
|
||||
const handleSync = useCallback(async (accountId: string) => {
|
||||
setSyncing(accountId);
|
||||
try {
|
||||
const result = await triggerSync(accountId);
|
||||
toast.success(t('mail.syncSuccess', { count: result.synced_count }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSyncing(null);
|
||||
}
|
||||
}, [toast, t]);
|
||||
|
||||
const activeTab = searchParams.get('tab') || 'accounts';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'accounts',
|
||||
label: t('mail.tabAccounts'),
|
||||
content: (
|
||||
<div className="space-y-4" data-testid="tab-accounts">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">{t('mail.accounts')}</h3>
|
||||
<Button size="sm" onClick={() => setShowAddAccount(!showAddAccount)} data-testid="add-account-btn">
|
||||
{t('mail.addAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAddAccount && (
|
||||
<Card className="mb-4" data-testid="add-account-form">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label={t('mail.email')}
|
||||
value={newAccount.email}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.displayName')}
|
||||
value={newAccount.display_name}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, display_name: e.target.value }))}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label={t('mail.imapHost')}
|
||||
value={newAccount.imap_host}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, imap_host: e.target.value }))}
|
||||
placeholder="imap.example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.imapPort')}
|
||||
type="number"
|
||||
value={String(newAccount.imap_port)}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, imap_port: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label={t('mail.smtpHost')}
|
||||
value={newAccount.smtp_host}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, smtp_host: e.target.value }))}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.smtpPort')}
|
||||
type="number"
|
||||
value={String(newAccount.smtp_port)}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, smtp_port: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={t('mail.password')}
|
||||
type="password"
|
||||
value={newAccount.password}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, password: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateAccount} isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAddAccount(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="animate-spin h-5 w-5 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>
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">{t('mail.noAccounts')}</p>
|
||||
) : (
|
||||
<div className="space-y-2" data-testid="account-list">
|
||||
{accounts.map((acc) => (
|
||||
<Card key={acc.id}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900">{acc.display_name}</p>
|
||||
<p className="text-sm text-secondary-500">{acc.email}</p>
|
||||
<p className="text-xs text-secondary-400">
|
||||
{acc.is_shared ? t('mail.shared') : t('mail.personal')} ·
|
||||
{acc.is_active ? t('common.active') : t('common.inactive')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleTestConnection(acc.id)}
|
||||
isLoading={testing === acc.id}
|
||||
data-testid={`test-conn-${acc.id}`}
|
||||
>
|
||||
{t('mail.testConnection')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleSync(acc.id)}
|
||||
isLoading={syncing === acc.id}
|
||||
data-testid={`sync-${acc.id}`}
|
||||
>
|
||||
{t('mail.syncNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'signatures',
|
||||
label: t('mail.tabSignatures'),
|
||||
content: <SignatureManager />,
|
||||
},
|
||||
{
|
||||
key: 'rules',
|
||||
label: t('mail.tabRules'),
|
||||
content: selectedAccountId ? <RuleEditor accountId={selectedAccountId} /> : (
|
||||
<p className="text-sm text-secondary-500">{t('mail.selectAccountFirst')}</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
label: t('mail.tabLabels'),
|
||||
content: <LabelManager />,
|
||||
},
|
||||
{
|
||||
key: 'vacation',
|
||||
label: t('mail.tabVacation'),
|
||||
content: <VacationResponder accountId={selectedAccountId} />,
|
||||
},
|
||||
{
|
||||
key: 'pgp',
|
||||
label: t('mail.tabPgp'),
|
||||
content: <PgpSettings />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto" data-testid="mail-settings-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('mail.settings')}</h1>
|
||||
<Tabs tabs={tabs} defaultKey={activeTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user