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:
@@ -22,7 +22,7 @@ const navItems: NavItem[] = [
|
||||
{ to: '/contacts', labelKey: 'nav.contacts', icon: navIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z') },
|
||||
{ to: '/calendar', labelKey: 'nav.calendar', icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') },
|
||||
{ to: '/dms', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
|
||||
{ to: '/email', labelKey: 'nav.email', icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
|
||||
{ to: '/mail', labelKey: 'nav.email', icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
|
||||
{ to: '/users', labelKey: 'nav.users', icon: navIcon('M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z') },
|
||||
{ to: '/audit-log', labelKey: 'nav.auditLog', icon: navIcon('M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z') },
|
||||
{ to: '/settings', labelKey: 'nav.settings', icon: navIcon('M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z') },
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Compose modal — rich text editor with toolbar (bold, italic, link, template insert).
|
||||
* Used for new mail, reply, and forward.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { TemplatePicker } from './TemplatePicker';
|
||||
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload } from '@/api/mail';
|
||||
|
||||
export type ComposeMode = 'new' | 'reply' | 'forward';
|
||||
|
||||
export interface ComposeModalProps {
|
||||
open: boolean;
|
||||
mode: ComposeMode;
|
||||
accountId: string;
|
||||
replyToMail: Mail | null;
|
||||
forwardMail: Mail | null;
|
||||
signatures: MailSignature[];
|
||||
onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ComposeModal({
|
||||
open,
|
||||
mode,
|
||||
accountId,
|
||||
replyToMail,
|
||||
forwardMail,
|
||||
signatures,
|
||||
onSend,
|
||||
onClose,
|
||||
}: ComposeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [to, setTo] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
const [bcc, setBcc] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [showCc, setShowCc] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === 'reply' && replyToMail) {
|
||||
setTo(replyToMail.from_address);
|
||||
setSubject(replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`);
|
||||
setBody(`\n\n---\n${replyToMail.body_text.slice(0, 200)}`);
|
||||
} else if (mode === 'forward' && forwardMail) {
|
||||
setTo('');
|
||||
setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`);
|
||||
setBody(`\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`);
|
||||
} else {
|
||||
setTo('');
|
||||
setCc('');
|
||||
setBcc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
}
|
||||
}, [open, mode, replyToMail, forwardMail, t]);
|
||||
|
||||
const execCommand = useCallback((command: string, value?: string) => {
|
||||
document.execCommand(command, false, value);
|
||||
if (editorRef.current) {
|
||||
setBody(editorRef.current.innerHTML);
|
||||
}
|
||||
editorRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const url = window.prompt(t('mail.linkUrl'));
|
||||
if (url) {
|
||||
execCommand('createLink', url);
|
||||
}
|
||||
}, [execCommand, t]);
|
||||
|
||||
const insertSignature = useCallback((signatureId: string) => {
|
||||
setSelectedSignatureId(signatureId);
|
||||
const sig = signatures.find((s) => s.id === signatureId);
|
||||
if (sig && editorRef.current) {
|
||||
const current = editorRef.current.innerHTML;
|
||||
editorRef.current.innerHTML = `${current}<br/><br/>---<br/>${sig.body_html}`;
|
||||
setBody(editorRef.current.innerHTML);
|
||||
}
|
||||
}, [signatures]);
|
||||
|
||||
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = templateBody;
|
||||
setBody(templateBody);
|
||||
}
|
||||
if (templateSubject) {
|
||||
setSubject(templateSubject);
|
||||
}
|
||||
setShowTemplatePicker(false);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!to.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
|
||||
if (mode === 'reply' && replyToMail) {
|
||||
const replyPayload: ReplyPayload = {
|
||||
account_id: accountId,
|
||||
body,
|
||||
is_html: true,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
signature_id: selectedSignatureId || null,
|
||||
};
|
||||
await onSend(replyPayload, 'reply');
|
||||
} else if (mode === 'forward' && forwardMail) {
|
||||
const fwdPayload: ForwardPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
body,
|
||||
is_html: true,
|
||||
signature_id: selectedSignatureId || null,
|
||||
};
|
||||
await onSend(fwdPayload, 'forward');
|
||||
} else {
|
||||
const sendPayload: SendMailPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
bcc: bccList,
|
||||
subject,
|
||||
body,
|
||||
is_html: true,
|
||||
signature_id: selectedSignatureId || null,
|
||||
};
|
||||
await onSend(sendPayload, 'new');
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, onSend, onClose]);
|
||||
|
||||
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose');
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false}>
|
||||
<div className="space-y-4" data-testid="compose-modal">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 border-b border-secondary-200 pb-2" data-testid="compose-toolbar">
|
||||
<button
|
||||
onClick={() => execCommand('bold')}
|
||||
className="p-2 rounded hover:bg-secondary-100 min-h-touch min-w-touch"
|
||||
aria-label={t('mail.bold')}
|
||||
title={t('mail.bold')}
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6V4z M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6v-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => execCommand('italic')}
|
||||
className="p-2 rounded hover:bg-secondary-100 min-h-touch min-w-touch"
|
||||
aria-label={t('mail.italic')}
|
||||
title={t('mail.italic')}
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 4l-7 16M10 4L3 20M4 8h6M14 8h6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={insertLink}
|
||||
className="p-2 rounded hover:bg-secondary-100 min-h-touch min-w-touch"
|
||||
aria-label={t('mail.insertLink')}
|
||||
title={t('mail.insertLink')}
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
||||
<button
|
||||
onClick={() => setShowTemplatePicker(!showTemplatePicker)}
|
||||
className="px-3 py-2 rounded hover:bg-secondary-100 text-sm min-h-touch"
|
||||
aria-label={t('mail.insertTemplate')}
|
||||
title={t('mail.insertTemplate')}
|
||||
type="button"
|
||||
data-testid="template-picker-toggle"
|
||||
>
|
||||
{t('mail.template')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showTemplatePicker && (
|
||||
<TemplatePicker onSelect={handleTemplateSelect} />
|
||||
)}
|
||||
|
||||
{/* Recipients */}
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label={t('mail.to')}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
placeholder="recipient@example.com"
|
||||
required
|
||||
data-testid="compose-to"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{!showCc ? (
|
||||
<button
|
||||
onClick={() => setShowCc(true)}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
type="button"
|
||||
>
|
||||
{t('mail.showCcBcc')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<Input
|
||||
label={t('mail.cc')}
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
placeholder="cc@example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.bcc')}
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
placeholder="bcc@example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
label={t('mail.subject')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t('mail.subjectPlaceholder')}
|
||||
data-testid="compose-subject"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.body')}</label>
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
||||
className="min-h-48 border border-secondary-300 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary-500 overflow-y-auto"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={t('mail.body')}
|
||||
data-testid="compose-editor"
|
||||
dangerouslySetInnerHTML={mode === 'reply' && replyToMail ? { __html: body } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Signature */}
|
||||
<Select
|
||||
label={t('mail.signature')}
|
||||
value={selectedSignatureId}
|
||||
onChange={(e) => insertSignature(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: t('mail.noSignature') },
|
||||
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-secondary-200">
|
||||
<Button variant="secondary" onClick={onClose} type="button">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSend} isLoading={sending} type="button" data-testid="compose-send">
|
||||
{t('mail.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Label manager — create labels with colors, assign to mails.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import {
|
||||
fetchLabels,
|
||||
createLabel,
|
||||
deleteLabel,
|
||||
type MailLabel,
|
||||
} from '@/api/mail';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
|
||||
'#8b5cf6', '#ec4899', '#6366f1', '#64748b',
|
||||
];
|
||||
|
||||
export function LabelManager() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [labels, setLabels] = useState<MailLabel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState(PRESET_COLORS[0]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MailLabel | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchLabels()
|
||||
.then((data) => {
|
||||
setLabels(data);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const label = await createLabel({ name, color });
|
||||
setLabels((prev) => [...prev, label]);
|
||||
toast.success(t('mail.labelCreated'));
|
||||
setShowForm(false);
|
||||
setName('');
|
||||
setColor(PRESET_COLORS[0]);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [name, color, toast, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteLabel(deleteTarget.id);
|
||||
setLabels((prev) => prev.filter((l) => l.id !== deleteTarget.id));
|
||||
toast.success(t('mail.labelDeleted'));
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [deleteTarget, toast, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8" data-testid="label-manager-loading">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="label-manager-error">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="label-manager">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">{t('mail.labels')}</h3>
|
||||
<Button size="sm" onClick={() => setShowForm(!showForm)} data-testid="new-label-btn">{t('mail.newLabel')}</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card className="mb-4" data-testid="label-form">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label={t('mail.labelName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('mail.labelName')}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-2">{t('mail.labelColor')}</label>
|
||||
<div className="flex flex-wrap gap-2" data-testid="label-color-picker">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={`w-8 h-8 rounded-full ${color === c ? 'ring-2 ring-offset-2 ring-primary-500' : ''}`}
|
||||
style={{ backgroundColor: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{labels.length === 0 && !showForm ? (
|
||||
<EmptyState title={t('mail.noLabels')} description={t('mail.noLabelsDesc')} />
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2" data-testid="label-list">
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm"
|
||||
style={{ backgroundColor: label.color, color: '#fff' }}
|
||||
>
|
||||
<span>{label.name}</span>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(label)}
|
||||
className="hover:opacity-70"
|
||||
aria-label={t('common.delete')}
|
||||
data-testid={`delete-label-${label.id}`}
|
||||
type="button"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title={t('mail.deleteLabel')}
|
||||
message={t('mail.confirmDeleteLabel')}
|
||||
confirmLabel={t('common.delete')}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Mail detail reading pane.
|
||||
* Shows mail headers, sanitized HTML body, attachments, reply/forward/create-event actions.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
import type { Mail, MailAttachment } from '@/api/mail';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
export interface MailDetailProps {
|
||||
mail: Mail | null; loading: boolean;
|
||||
onReply: (mail: Mail) => void;
|
||||
onForward: (mail: Mail) => void;
|
||||
onCreateEvent: (mail: Mail) => void;
|
||||
onToggleFlag: (mail: Mail) => void;
|
||||
onDownloadAttachment: (mailId: string, attachment: MailAttachment) => void;
|
||||
downloadingAttachmentId: string | null;
|
||||
}
|
||||
|
||||
function formatFullDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function isSanitizedHtmlSafe(html: string | null): boolean {
|
||||
if (!html) return true;
|
||||
const lower = html.toLowerCase();
|
||||
const dangerous = ['<script', 'javascript:', 'onerror=', 'onload=', 'onclick=', '<iframe'];
|
||||
return !dangerous.some((pattern) => lower.includes(pattern));
|
||||
|
||||
}
|
||||
|
||||
export function MailDetail({
|
||||
mail,
|
||||
loading,
|
||||
onReply,
|
||||
onForward,
|
||||
onCreateEvent,
|
||||
onToggleFlag,
|
||||
onDownloadAttachment,
|
||||
downloadingAttachmentId,
|
||||
}: MailDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const safeHtml = useMemo(() => {
|
||||
if (!mail) return null;
|
||||
return mail.sanitized_html || mail.body_html;
|
||||
}, [mail]);
|
||||
|
||||
const isSafe = useMemo(() => isSanitizedHtmlSafe(safeHtml), [safeHtml]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" data-testid="mail-detail-loading">
|
||||
<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>
|
||||
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mail) {
|
||||
return (
|
||||
<div data-testid="mail-detail-empty">
|
||||
<EmptyState
|
||||
title={t('mail.selectMailToRead')}
|
||||
description={t('mail.selectMailToReadDesc')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="mail-detail">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-secondary-200" data-testid="mail-detail-toolbar">
|
||||
<Button variant="secondary" size="sm" onClick={() => onReply(mail)} icon={
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
}>
|
||||
{t('mail.reply')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => onForward(mail)} icon={
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10H11a8 8 0 00-8 8v2m18-10l-6-6m6 6l-6 6" />
|
||||
</svg>
|
||||
}>
|
||||
{t('mail.forward')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleFlag(mail)}
|
||||
aria-label={t('mail.toggleFlag')}
|
||||
icon={
|
||||
<svg className={clsx('w-4 h-4', mail.is_flagged ? 'text-warning-500' : 'text-secondary-400')} fill={mail.is_flagged ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
{mail.is_flagged ? t('mail.unflag') : t('mail.flag')}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onCreateEvent(mail)}
|
||||
icon={
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
{t('mail.createEvent')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<div className="px-4 py-3 border-b border-secondary-200" data-testid="mail-detail-headers">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 flex-1">{mail.subject || t('mail.noSubject')}</h2>
|
||||
{mail.labels && mail.labels.length > 0 && (
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
{mail.labels.map((label) => (
|
||||
<span key={label.id} className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white" style={{ backgroundColor: label.color }}>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-secondary-600">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-secondary-500">{t('mail.from')}:</span>
|
||||
<span>{mail.from_name ? `${mail.from_name} <${mail.from_address}>` : mail.from_address}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-secondary-500">{t('mail.to')}:</span>
|
||||
<span>{mail.to_addresses.join(', ')}</span>
|
||||
</div>
|
||||
{mail.cc_addresses.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-secondary-500">{t('mail.cc')}:</span>
|
||||
<span>{mail.cc_addresses.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-secondary-500">{t('mail.date')}:</span>
|
||||
<span>{formatFullDate(mail.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4" data-testid="mail-detail-body">
|
||||
{safeHtml && isSafe ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-secondary-800"
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
data-testid="mail-html-body"
|
||||
/>
|
||||
) : safeHtml && !isSafe ? (
|
||||
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert">
|
||||
<p className="text-sm text-danger-700">{t('mail.htmlUnsafe')}</p>
|
||||
<pre className="mt-2 text-xs text-secondary-600 whitespace-pre-wrap">{mail.body_text}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm text-secondary-800" data-testid="mail-text-body">{mail.body_text}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{mail.attachments && mail.attachments.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-secondary-200" data-testid="mail-detail-attachments">
|
||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('mail.attachments')}</h3>
|
||||
<ul className="space-y-2">
|
||||
{mail.attachments.map((att) => (
|
||||
<li key={att.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 min-h-touch">
|
||||
<svg className="w-5 h-5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-secondary-800 truncate">{att.filename}</p>
|
||||
<p className="text-xs text-secondary-400">{formatBytes(att.size)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDownloadAttachment(mail.id, att)}
|
||||
isLoading={downloadingAttachmentId === att.id}
|
||||
data-testid={`download-attachment-${att.id}`}
|
||||
>
|
||||
{t('mail.download')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Mail folder tree sidebar component.
|
||||
* Shows hierarchical folder list with unread badges.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MailFolder } from '@/api/mail';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
export interface MailFolderTreeProps {
|
||||
folders: MailFolder[];
|
||||
selectedFolderId: string | null;
|
||||
onSelect: (folderId: string) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function FolderNode({
|
||||
folder,
|
||||
selectedFolderId,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
folder: MailFolder;
|
||||
selectedFolderId: string | null;
|
||||
onSelect: (folderId: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isSelected = folder.id === selectedFolderId;
|
||||
const hasChildren = folder.children && folder.children.length > 0;
|
||||
|
||||
return (
|
||||
<div role="treeitem" aria-selected={isSelected} aria-label={folder.name}>
|
||||
<button
|
||||
onClick={() => onSelect(folder.id)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm min-h-touch',
|
||||
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isSelected
|
||||
? 'bg-primary-50 text-primary-700 font-medium'
|
||||
: 'hover:bg-secondary-50 text-secondary-700',
|
||||
)}
|
||||
data-testid={`folder-${folder.id}`}
|
||||
>
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="flex-1 truncate text-left">{folder.name}</span>
|
||||
{folder.unread_count > 0 && (
|
||||
<Badge variant="primary" className="text-xs">{folder.unread_count}</Badge>
|
||||
)}
|
||||
{folder.total_count > 0 && folder.unread_count === 0 && (
|
||||
<span className="text-xs text-secondary-400">{folder.total_count}</span>
|
||||
)}
|
||||
</button>
|
||||
{hasChildren && (
|
||||
<div className="ml-4" role="group">
|
||||
{folder.children!.map((child) => (
|
||||
<FolderNode
|
||||
key={child.id}
|
||||
folder={child}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8" data-testid="folder-tree-loading">
|
||||
<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>
|
||||
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (folders.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('mail.noFolders')}
|
||||
description={t('mail.noFoldersDesc')}
|
||||
data-testid="folder-tree-empty"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1" role="tree" data-testid="folder-tree-list">
|
||||
{folders.map((folder) => (
|
||||
<FolderNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelect={onSelect}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Mail list component with pagination.
|
||||
* Shows list of mails in selected folder with seen/flagged indicators.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Mail } from '@/api/mail';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
|
||||
export interface MailListProps {
|
||||
mails: Mail[];
|
||||
selectedMailId: string | null;
|
||||
onSelectMail: (mail: Mail) => void;
|
||||
loading: boolean;
|
||||
currentPage: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function MailList({
|
||||
mails,
|
||||
selectedMailId,
|
||||
onSelectMail,
|
||||
loading,
|
||||
currentPage,
|
||||
total,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
}: MailListProps) {
|
||||
const { t } = useTranslation();
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" data-testid="mail-list-loading">
|
||||
<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>
|
||||
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mails.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('mail.noMails')}
|
||||
description={t('mail.noMailsDesc')}
|
||||
data-testid="mail-list-empty"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
|
||||
<ul className="divide-y divide-secondary-100" role="list">
|
||||
{mails.map((mail) => (
|
||||
<li key={mail.id} role="listitem">
|
||||
<button
|
||||
onClick={() => onSelectMail(mail)}
|
||||
className={clsx(
|
||||
'w-full text-left px-4 py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
mail.id === selectedMailId && 'bg-primary-50',
|
||||
!mail.is_seen && 'font-semibold',
|
||||
)}
|
||||
aria-selected={mail.id === selectedMailId}
|
||||
data-testid={`mail-item-${mail.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{!mail.is_seen && (
|
||||
<span className="w-2 h-2 rounded-full bg-primary-500 flex-shrink-0" aria-label={t('mail.unread')} />
|
||||
)}
|
||||
{mail.is_flagged && (
|
||||
<svg className="w-4 h-4 text-warning-500 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24" aria-label={t('mail.flagged')}>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
)}
|
||||
{mail.has_attachments && (
|
||||
<svg className="w-4 h-4 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 13l-3.586 3.586a2 2 0 01-2.828 0L5 12.828a2 2 0 010-2.828l5.657-5.657a2 2 0 012.828 0L17 6.343M14.828 8.172a2 2 0 00-2.828 0l-3.586 3.586a2 2 0 000 2.828l1.414 1.414a2 2 0 002.828 0" />
|
||||
</svg>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
|
||||
{mail.from_name || mail.from_address}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
|
||||
</div>
|
||||
<p className={clsx('text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
||||
{mail.subject || t('mail.noSubject')}
|
||||
</p>
|
||||
{mail.labels && mail.labels.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{mail.labels.map((label) => (
|
||||
<span key={label.id} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs text-white" style={{ backgroundColor: label.color }}>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Mail search bar — input for full-text mail search.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
export interface MailSearchBarProps {
|
||||
onSearch: (query: string) => void;
|
||||
}
|
||||
|
||||
export function MailSearchBar({ onSearch }: MailSearchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch(query.trim());
|
||||
}, [query, onSearch]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="relative" data-testid="mail-search-bar">
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
placeholder={t('mail.searchPlaceholder')}
|
||||
aria-label={t('common.search')}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch"
|
||||
aria-label={t('common.search')}
|
||||
>
|
||||
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* PGP settings — import private key, view contact public keys.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
importPgpKey,
|
||||
fetchPgpKeys,
|
||||
fetchContactPgpKeys,
|
||||
storeContactPgpKey,
|
||||
type PgpKey,
|
||||
type ContactPgpKey,
|
||||
} from '@/api/mail';
|
||||
|
||||
export function PgpSettings() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [keys, setKeys] = useState<PgpKey[]>([]);
|
||||
const [contactKeys, setContactKeys] = useState<ContactPgpKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [privateKey, setPrivateKey] = useState('');
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [contactId, setContactId] = useState('');
|
||||
const [contactPublicKey, setContactPublicKey] = useState('');
|
||||
const [storingContact, setStoringContact] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([fetchPgpKeys(), fetchContactPgpKeys()])
|
||||
.then(([k, ck]) => {
|
||||
setKeys(k);
|
||||
setContactKeys(ck);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!privateKey.trim()) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const result = await importPgpKey({ private_key: privateKey, passphrase: passphrase || undefined });
|
||||
setKeys((prev) => [...prev, result]);
|
||||
toast.success(t('mail.pgpKeyImported'));
|
||||
setPrivateKey('');
|
||||
setPassphrase('');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [privateKey, passphrase, toast, t]);
|
||||
|
||||
const handleStoreContactKey = useCallback(async () => {
|
||||
if (!contactId.trim() || !contactPublicKey.trim()) return;
|
||||
setStoringContact(true);
|
||||
try {
|
||||
await storeContactPgpKey(contactId, { public_key: contactPublicKey });
|
||||
toast.success(t('mail.contactPgpStored'));
|
||||
setContactId('');
|
||||
setContactPublicKey('');
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setStoringContact(false);
|
||||
}
|
||||
}, [contactId, contactPublicKey, toast, t, load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8" data-testid="pgp-settings-loading">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="pgp-settings-error">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="pgp-settings">
|
||||
<Card title={t('mail.pgpImportKey')}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.privateKey')}</label>
|
||||
<textarea
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
className="w-full min-h-32 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----"
|
||||
data-testid="pgp-private-key"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={t('mail.passphrase')}
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={t('mail.passphrase')}
|
||||
/>
|
||||
<Button onClick={handleImport} isLoading={importing} size="sm" data-testid="pgp-import-btn">
|
||||
{t('mail.importKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('mail.pgpKeys')}>
|
||||
{keys.length === 0 ? (
|
||||
<EmptyState title={t('mail.noPgpKeys')} description={t('mail.noPgpKeysDesc')} />
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="pgp-key-list">
|
||||
{keys.map((key) => (
|
||||
<li key={key.id} className="p-3 rounded-md border border-secondary-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-900">{key.user_id}</p>
|
||||
<p className="text-xs text-secondary-500">Key ID: {key.key_id}</p>
|
||||
<p className="text-xs text-secondary-400">Fingerprint: {key.fingerprint}</p>
|
||||
</div>
|
||||
<span className="text-xs text-secondary-400">{key.is_private ? t('mail.privateKeyLabel') : t('mail.publicKey')}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title={t('mail.contactPgpKeys')}>
|
||||
<div className="space-y-3 mb-4">
|
||||
<Input
|
||||
label={t('mail.contactId')}
|
||||
value={contactId}
|
||||
onChange={(e) => setContactId(e.target.value)}
|
||||
placeholder="contact-uuid"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.publicKey')}</label>
|
||||
<textarea
|
||||
value={contactPublicKey}
|
||||
onChange={(e) => setContactPublicKey(e.target.value)}
|
||||
className="w-full min-h-24 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"
|
||||
data-testid="contact-public-key"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleStoreContactKey} isLoading={storingContact} size="sm" data-testid="store-contact-key-btn">
|
||||
{t('mail.storeContactKey')}
|
||||
</Button>
|
||||
</div>
|
||||
{contactKeys.length === 0 ? (
|
||||
<EmptyState title={t('mail.noContactKeys')} description={t('mail.noContactKeysDesc')} />
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="contact-pgp-list">
|
||||
{contactKeys.map((ck) => (
|
||||
<li key={ck.contact_id} className="p-3 rounded-md border border-secondary-200">
|
||||
<p className="text-sm font-medium text-secondary-900">{ck.contact_name}</p>
|
||||
<p className="text-xs text-secondary-500">Key ID: {ck.key_id}</p>
|
||||
<p className="text-xs text-secondary-400">Fingerprint: {ck.fingerprint}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Rule editor — condition builder + action selector for mail rules.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import {
|
||||
fetchRules,
|
||||
createRule,
|
||||
deleteRule,
|
||||
type MailRule,
|
||||
type RuleCondition,
|
||||
type RuleAction,
|
||||
type RuleConditionType,
|
||||
type RuleActionType,
|
||||
} from '@/api/mail';
|
||||
|
||||
const conditionTypeOptions = (t: (s: string) => string) => [
|
||||
{ value: 'from_contains', label: t('mail.ruleCondFromContains') },
|
||||
{ value: 'to_contains', label: t('mail.ruleCondToContains') },
|
||||
{ value: 'subject_contains', label: t('mail.ruleCondSubjectContains') },
|
||||
{ value: 'body_contains', label: t('mail.ruleCondBodyContains') },
|
||||
{ value: 'has_attachment', label: t('mail.ruleCondHasAttachment') },
|
||||
];
|
||||
|
||||
const actionTypeOptions = (t: (s: string) => string) => [
|
||||
{ value: 'move_to_folder', label: t('mail.ruleActionMoveToFolder') },
|
||||
{ value: 'mark_as_read', label: t('mail.ruleActionMarkAsRead') },
|
||||
{ value: 'mark_as_flagged', label: t('mail.ruleActionMarkAsFlagged') },
|
||||
{ value: 'forward_to', label: t('mail.ruleActionForwardTo') },
|
||||
{ value: 'delete', label: t('mail.ruleActionDelete') },
|
||||
];
|
||||
|
||||
export function RuleEditor({ accountId }: { accountId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [rules, setRules] = useState<MailRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [priority, setPriority] = useState(1);
|
||||
const [conditions, setConditions] = useState<RuleCondition[]>([{ type: 'from_contains', value: '' }]);
|
||||
const [actions, setActions] = useState<RuleAction[]>([{ type: 'mark_as_read', value: '' }]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MailRule | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchRules()
|
||||
.then((data) => {
|
||||
setRules(data);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const addCondition = () => {
|
||||
setConditions((prev) => [...prev, { type: 'from_contains', value: '' }]);
|
||||
};
|
||||
|
||||
const updateCondition = (index: number, field: 'type' | 'value', val: string) => {
|
||||
setConditions((prev) => prev.map((c, i) => (i === index ? { ...c, [field]: val } : c)));
|
||||
};
|
||||
|
||||
const removeCondition = (index: number) => {
|
||||
setConditions((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const addAction = () => {
|
||||
setActions((prev) => [...prev, { type: 'mark_as_read', value: '' }]);
|
||||
};
|
||||
|
||||
const updateAction = (index: number, field: 'type' | 'value', val: string) => {
|
||||
setActions((prev) => prev.map((a, i) => (i === index ? { ...a, [field]: val } : a)));
|
||||
};
|
||||
|
||||
const removeAction = (index: number) => {
|
||||
setActions((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const rule = await createRule({
|
||||
name,
|
||||
account_id: accountId,
|
||||
priority,
|
||||
is_active: true,
|
||||
conditions,
|
||||
actions,
|
||||
});
|
||||
setRules((prev) => [...prev, rule].sort((a, b) => a.priority - b.priority));
|
||||
toast.success(t('mail.ruleCreated'));
|
||||
setShowForm(false);
|
||||
setName('');
|
||||
setPriority(1);
|
||||
setConditions([{ type: 'from_contains', value: '' }]);
|
||||
setActions([{ type: 'mark_as_read', value: '' }]);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [name, accountId, priority, conditions, actions, toast, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteRule(deleteTarget.id);
|
||||
setRules((prev) => prev.filter((r) => r.id !== deleteTarget.id));
|
||||
toast.success(t('mail.ruleDeleted'));
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [deleteTarget, toast, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8" data-testid="rule-editor-loading">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="rule-editor-error">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="rule-editor">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">{t('mail.rules')}</h3>
|
||||
<Button size="sm" onClick={() => setShowForm(!showForm)} data-testid="new-rule-btn">{t('mail.newRule')}</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card className="mb-4" data-testid="rule-form">
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label={t('mail.ruleName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('mail.ruleName')}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.rulePriority')}
|
||||
type="number"
|
||||
value={String(priority)}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
/>
|
||||
|
||||
{/* Conditions */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-700 mb-2">{t('mail.ruleConditions')}</p>
|
||||
<div className="space-y-2" data-testid="rule-conditions">
|
||||
{conditions.map((cond, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={cond.type}
|
||||
onChange={(e) => updateCondition(idx, 'type', e.target.value as RuleConditionType)}
|
||||
options={conditionTypeOptions(t)}
|
||||
className="flex-1"
|
||||
/>
|
||||
{cond.type !== 'has_attachment' && (
|
||||
<Input
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(idx, 'value', e.target.value)}
|
||||
placeholder={t('mail.ruleCondValue')}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => removeCondition(idx)} type="button">−</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={addCondition} className="mt-2" type="button">{t('mail.addCondition')}</Button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-700 mb-2">{t('mail.ruleActions')}</p>
|
||||
<div className="space-y-2" data-testid="rule-actions">
|
||||
{actions.map((act, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={act.type}
|
||||
onChange={(e) => updateAction(idx, 'type', e.target.value as RuleActionType)}
|
||||
options={actionTypeOptions(t)}
|
||||
className="flex-1"
|
||||
/>
|
||||
{(act.type === 'move_to_folder' || act.type === 'forward_to') && (
|
||||
<Input
|
||||
value={act.value}
|
||||
onChange={(e) => updateAction(idx, 'value', e.target.value)}
|
||||
placeholder={t('mail.ruleActionValue')}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => removeAction(idx)} type="button">−</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={addAction} className="mt-2" type="button">{t('mail.addAction')}</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{rules.length === 0 && !showForm ? (
|
||||
<EmptyState title={t('mail.noRules')} description={t('mail.noRulesDesc')} />
|
||||
) : (
|
||||
<div className="space-y-2" data-testid="rule-list">
|
||||
{rules.map((rule) => (
|
||||
<Card key={rule.id}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary-400">#{rule.priority}</span>
|
||||
<p className="font-medium text-secondary-900">{rule.name}</p>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 mt-1">
|
||||
{rule.conditions.length} {t('mail.ruleConditions')} → {rule.actions.length} {t('mail.ruleActions')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(rule)} data-testid={`delete-rule-${rule.id}`}>{t('common.delete')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title={t('mail.deleteRule')}
|
||||
message={t('mail.confirmDeleteRule')}
|
||||
confirmLabel={t('common.delete')}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shared mailbox selector — switch between personal and shared accounts.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import type { MailAccount } from '@/api/mail';
|
||||
|
||||
export interface SharedMailboxSelectorProps {
|
||||
accounts: MailAccount[];
|
||||
selectedAccountId: string;
|
||||
onSelect: (accountId: string) => void;
|
||||
}
|
||||
|
||||
export function SharedMailboxSelector({ accounts, selectedAccountId, onSelect }: SharedMailboxSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = accounts.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: `${acc.display_name} (${acc.email})${acc.is_shared ? ' — ' + t('mail.shared') : ''}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div data-testid="shared-mailbox-selector">
|
||||
<Select
|
||||
label={t('mail.selectAccount')}
|
||||
value={selectedAccountId}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Signature manager — CRUD for mail signatures.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import {
|
||||
fetchSignatures,
|
||||
createSignature,
|
||||
updateSignature,
|
||||
deleteSignature,
|
||||
type MailSignature,
|
||||
} from '@/api/mail';
|
||||
|
||||
export function SignatureManager() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [signatures, setSignatures] = useState<MailSignature[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<MailSignature | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [bodyHtml, setBodyHtml] = useState('');
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MailSignature | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchSignatures()
|
||||
.then((data) => {
|
||||
setSignatures(data);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleNew = () => {
|
||||
setEditing(null);
|
||||
setName('');
|
||||
setBodyHtml('');
|
||||
setIsDefault(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (sig: MailSignature) => {
|
||||
setEditing(sig);
|
||||
setName(sig.name);
|
||||
setBodyHtml(sig.body_html);
|
||||
setIsDefault(sig.is_default);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await updateSignature(editing.id, { name, body_html: bodyHtml, is_default: isDefault });
|
||||
setSignatures((prev) => prev.map((s) => (s.id === editing.id ? updated : s)));
|
||||
toast.success(t('mail.signatureUpdated'));
|
||||
} else {
|
||||
const created = await createSignature({ name, body_html: bodyHtml, is_default: isDefault });
|
||||
setSignatures((prev) => [...prev, created]);
|
||||
toast.success(t('mail.signatureCreated'));
|
||||
}
|
||||
setShowForm(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editing, name, bodyHtml, isDefault, toast, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteSignature(deleteTarget.id);
|
||||
setSignatures((prev) => prev.filter((s) => s.id !== deleteTarget.id));
|
||||
toast.success(t('mail.signatureDeleted'));
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [deleteTarget, toast, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8" data-testid="signature-manager-loading">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="signature-manager-error">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="signature-manager">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">{t('mail.signatures')}</h3>
|
||||
<Button size="sm" onClick={handleNew} data-testid="new-signature-btn">{t('mail.newSignature')}</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card className="mb-4" data-testid="signature-form">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label={t('mail.signatureName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('mail.signatureName')}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.signatureBody')}</label>
|
||||
<textarea
|
||||
value={bodyHtml}
|
||||
onChange={(e) => setBodyHtml(e.target.value)}
|
||||
className="w-full min-h-24 border border-secondary-300 rounded-md p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="<p>Regards, John</p>"
|
||||
data-testid="signature-body"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-secondary-700">
|
||||
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} className="rounded" />
|
||||
{t('mail.defaultSignature')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{signatures.length === 0 && !showForm ? (
|
||||
<EmptyState title={t('mail.noSignatures')} description={t('mail.noSignaturesDesc')} />
|
||||
) : (
|
||||
<div className="space-y-2" data-testid="signature-list">
|
||||
{signatures.map((sig) => (
|
||||
<Card key={sig.id}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-secondary-900">{sig.name}</p>
|
||||
{sig.is_default && <span className="text-xs text-primary-600">{t('mail.defaultSignature')}</span>}
|
||||
<p className="text-sm text-secondary-500 mt-1 truncate" dangerouslySetInnerHTML={{ __html: sig.body_html }} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(sig)} data-testid={`edit-signature-${sig.id}`}>{t('common.edit')}</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(sig)} data-testid={`delete-signature-${sig.id}`}>{t('common.delete')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title={t('mail.deleteSignature')}
|
||||
message={t('mail.confirmDeleteSignature')}
|
||||
confirmLabel={t('common.delete')}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Template picker dropdown for compose modal.
|
||||
* Lists available templates and inserts selected template body into editor.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchTemplates, substituteTemplate, type MailTemplate } from '@/api/mail';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
export interface TemplatePickerProps {
|
||||
onSelect: (body: string, subject: string) => void;
|
||||
}
|
||||
|
||||
export function TemplatePicker({ onSelect }: TemplatePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchTemplates()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setTemplates(data);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const handleSelect = (template: MailTemplate) => {
|
||||
substituteTemplate({
|
||||
template_id: template.id,
|
||||
variables: {},
|
||||
})
|
||||
.then((result) => {
|
||||
onSelect(result.body, result.subject || template.subject);
|
||||
})
|
||||
.catch(() => {
|
||||
onSelect(template.body, template.subject);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4" data-testid="template-picker-loading">
|
||||
<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>
|
||||
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="template-picker-error">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<EmptyState title={t('mail.noTemplates')} description={t('mail.noTemplatesDesc')} data-testid="template-picker-empty" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-md p-2 bg-secondary-50" data-testid="template-picker">
|
||||
<p className="text-sm font-medium text-secondary-700 mb-2">{t('mail.selectTemplate')}</p>
|
||||
<ul className="space-y-1" role="list">
|
||||
{templates.map((template) => (
|
||||
<li key={template.id}>
|
||||
<button
|
||||
onClick={() => handleSelect(template)}
|
||||
className="w-full text-left px-3 py-2 rounded-md hover:bg-white text-sm min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
data-testid={`template-option-${template.id}`}
|
||||
>
|
||||
<span className="font-medium text-secondary-800">{template.name}</span>
|
||||
{template.variables.length > 0 && (
|
||||
<span className="ml-2 text-xs text-secondary-400">({template.variables.join(', ')})</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Vacation responder — toggle + date range + auto-reply text.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { configureVacation, type VacationPayload } from '@/api/mail';
|
||||
|
||||
export function VacationResponder({ accountId }: { accountId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: VacationPayload = {
|
||||
enabled,
|
||||
start_date: startDate || null,
|
||||
end_date: endDate || null,
|
||||
subject,
|
||||
body,
|
||||
};
|
||||
await configureVacation(payload);
|
||||
toast.success(t('mail.vacationSaved'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [enabled, startDate, endDate, subject, body, toast, t]);
|
||||
|
||||
return (
|
||||
<div data-testid="vacation-responder">
|
||||
<Card title={t('mail.vacationResponder')}>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
data-testid="vacation-toggle"
|
||||
/>
|
||||
<span className="text-sm font-medium text-secondary-700">{t('mail.enableVacation')}</span>
|
||||
</label>
|
||||
|
||||
{enabled && (
|
||||
<div className="space-y-3" data-testid="vacation-settings">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input
|
||||
label={t('mail.vacationStart')}
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.vacationEnd')}
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={t('mail.vacationSubject')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t('mail.vacationSubjectPlaceholder')}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.vacationBody')}</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="w-full min-h-32 border border-secondary-300 rounded-md p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder={t('mail.vacationBodyPlaceholder')}
|
||||
data-testid="vacation-body"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} isLoading={saving} size="sm" data-testid="vacation-save">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user