feat: mail phase 2 - delete, move, drafts

- Add deleted_at field to Mail/MailAccount/MailFolder models
- Add DELETE /{mail_id} endpoint (soft-delete + IMAP EXPUNGE)
- Add POST /{mail_id}/move endpoint (IMAP UID MOVE + folder_id update)
- Add POST /drafts and PUT /drafts/{id} endpoints (save/edit drafts)
- Add imap_delete_mail() and imap_move_mail() service functions
- Add save_draft() and update_draft() service functions
- Frontend: deleteMail, moveMail, saveDraft, updateDraft API functions
- ComposeModal: draft mode with Save Draft button
- MailDetail: delete/move buttons, edit-draft for drafts
- Mail.tsx: bulk delete/move, move dropdown, draft handlers
- i18n: 13 new keys (de/en)
This commit is contained in:
Agent Zero
2026-07-15 19:44:41 +02:00
parent df83cee10c
commit ed1eec87dc
10 changed files with 950 additions and 21 deletions
+49 -4
View File
@@ -10,10 +10,10 @@ 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';
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail';
import { uploadAttachment, type UploadedAttachment } from '@/api/mail';
export type ComposeMode = 'new' | 'reply' | 'forward';
export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft';
export interface ComposeModalProps {
open: boolean;
@@ -21,8 +21,10 @@ export interface ComposeModalProps {
accountId: string;
replyToMail: Mail | null;
forwardMail: Mail | null;
draftMail?: Mail | null;
signatures: MailSignature[];
onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise<void>;
onSaveDraft?: (payload: MailDraftPayload) => Promise<void>;
onClose: () => void;
}
@@ -32,8 +34,10 @@ export function ComposeModal({
accountId,
replyToMail,
forwardMail,
draftMail,
signatures,
onSend,
onSaveDraft,
onClose,
}: ComposeModalProps) {
const { t } = useTranslation();
@@ -45,6 +49,7 @@ export function ComposeModal({
const [body, setBody] = useState('');
const [showCc, setShowCc] = useState(false);
const [sending, setSending] = useState(false);
const [savingDraft, setSavingDraft] = useState(false);
const [selectedSignatureId, setSelectedSignatureId] = useState('');
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
@@ -61,6 +66,13 @@ export function ComposeModal({
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 if (mode === 'draft' && draftMail) {
setTo(draftMail.to_addresses.join(', '));
setCc(draftMail.cc_addresses.join(', '));
setBcc(draftMail.bcc_addresses.join(', '));
setSubject(draftMail.subject);
setBody(draftMail.body_html || draftMail.body_text || '');
setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0);
} else {
setTo('');
setCc('');
@@ -69,7 +81,7 @@ export function ComposeModal({
setBody('');
setAttachments([]);
}
}, [open, mode, replyToMail, forwardMail, t]);
}, [open, mode, replyToMail, forwardMail, draftMail, t]);
const execCommand = useCallback((command: string, value?: string) => {
document.execCommand(command, false, value);
@@ -142,6 +154,28 @@ export function ComposeModal({
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const handleSaveDraft = useCallback(async () => {
if (!onSaveDraft) return;
setSavingDraft(true);
try {
const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : [];
const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : [];
const payload: MailDraftPayload = {
account_id: accountId,
to: toList,
cc: ccList,
bcc: bccList,
subject,
body_text: body.replace(/<[^>]*>/g, ''),
body_html: body,
};
await onSaveDraft(payload);
} finally {
setSavingDraft(false);
}
}, [to, cc, bcc, subject, body, accountId, onSaveDraft]);
const handleSend = useCallback(async () => {
if (!to.trim()) return;
setSending(true);
@@ -190,7 +224,7 @@ export function ComposeModal({
}
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]);
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose');
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : mode === 'draft' ? t('mail.editDraft') : t('mail.compose');
return (
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false} fullScreenMobile>
@@ -379,6 +413,17 @@ export function ComposeModal({
<Button variant="secondary" onClick={onClose} type="button">
{t('common.cancel')}
</Button>
{onSaveDraft && (
<Button
variant="secondary"
onClick={handleSaveDraft}
isLoading={savingDraft}
type="button"
data-testid="compose-save-draft"
>
{t('mail.saveDraft')}
</Button>
)}
<Button onClick={handleSend} isLoading={sending} type="button" data-testid="compose-send">
{t('mail.send')}
</Button>
+60 -14
View File
@@ -19,6 +19,9 @@ export interface MailDetailProps {
onToggleFlag: (mail: Mail) => void;
onDownloadAttachment: (mailId: string, attachment: MailAttachment) => void;
downloadingAttachmentId: string | null;
onDelete?: (mail: Mail) => void;
onMove?: (mail: Mail) => void;
onEditDraft?: (mail: Mail) => void;
}
function formatFullDate(dateStr: string): string {
@@ -47,6 +50,9 @@ export function MailDetail({
onToggleFlag,
onDownloadAttachment,
downloadingAttachmentId,
onDelete,
onMove,
onEditDraft,
}: MailDetailProps) {
const { t } = useTranslation();
@@ -98,20 +104,32 @@ export function MailDetail({
<div className="flex flex-col h-full" data-testid="mail-detail">
{/* Toolbar */}
<div className="flex items-center gap-1 sm:gap-2 px-3 py-2 md:px-4 md:py-3 border-b border-secondary-200 overflow-x-auto" 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>
{mail.is_draft && onEditDraft ? (
<Button variant="secondary" size="sm" onClick={() => onEditDraft(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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
}>
{t('mail.editDraft')}
</Button>
) : (
<>
<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"
@@ -125,6 +143,20 @@ export function MailDetail({
>
{mail.is_flagged ? t('mail.unflag') : t('mail.flag')}
</Button>
{onMove && (
<Button
variant="ghost"
size="sm"
onClick={() => onMove(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="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
}
>
{t('mail.move')}
</Button>
)}
<div className="flex-1" />
<Button
variant="ghost"
@@ -138,6 +170,20 @@ export function MailDetail({
>
{t('mail.createEvent')}
</Button>
{onDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
}
>
{t('mail.delete')}
</Button>
)}
</div>
{/* Headers */}