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
+204 -1
View File
@@ -32,6 +32,10 @@ import {
downloadAttachment,
fetchSignatures,
searchMails,
deleteMail,
moveMail,
saveDraft,
updateDraft,
type MailAccount,
type MailFolder,
type Mail,
@@ -41,6 +45,7 @@ import {
type ReplyPayload,
type ForwardPayload,
type CreateEventFromMailPayload,
type MailDraftPayload,
} from '@/api/mail';
const PAGE_SIZE = 25;
@@ -67,10 +72,12 @@ export function MailPage() {
const [composeMode, setComposeMode] = useState<ComposeMode>('new');
const [replyToMail, setReplyToMail] = useState<Mail | null>(null);
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
const [draftMailState, setDraftMailState] = useState<Mail | null>(null);
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
const [selectedMailIds, setSelectedMailIds] = useState<Set<string>>(new Set());
const [showMoveDropdown, setShowMoveDropdown] = useState(false);
// Load accounts
const loadAccounts = useCallback(async () => {
@@ -204,6 +211,7 @@ export function MailPage() {
setComposeMode('new');
setReplyToMail(null);
setForwardMailState(null);
setDraftMailState(null);
setComposeOpen(true);
}, []);
@@ -212,6 +220,7 @@ export function MailPage() {
setComposeMode('reply');
setReplyToMail(mail);
setForwardMailState(null);
setDraftMailState(null);
setComposeOpen(true);
}, []);
@@ -220,6 +229,98 @@ export function MailPage() {
setComposeMode('forward');
setForwardMailState(mail);
setReplyToMail(null);
setDraftMailState(null);
setComposeOpen(true);
}, []);
// Handle delete single mail
const handleDeleteMail = useCallback(
async (mail: Mail) => {
if (!window.confirm(t('mail.deleteConfirm'))) return;
try {
await deleteMail(mail.id);
setMails((prev) => prev.filter((m) => m.id !== mail.id));
setSelectedMail(null);
toast.success(t('mail.delete'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[toast, t],
);
// Handle bulk delete
const handleBulkDelete = useCallback(async () => {
if (!window.confirm(t('mail.bulkDeleteConfirm'))) return;
const ids = Array.from(selectedMailIds);
try {
await Promise.all(ids.map((id) => deleteMail(id)));
setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id)));
setSelectedMailIds(new Set());
setSelectedMail(null);
toast.success(t('mail.delete'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [selectedMailIds, toast, t]);
// Handle move single mail
const handleMoveMail = useCallback(
async (mail: Mail, targetFolderId: string) => {
try {
await moveMail(mail.id, targetFolderId);
setMails((prev) => prev.filter((m) => m.id !== mail.id));
setSelectedMail(null);
setShowMoveDropdown(false);
toast.success(t('mail.move'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[toast, t],
);
// Handle bulk move
const handleBulkMove = useCallback(
async (targetFolderId: string) => {
const ids = Array.from(selectedMailIds);
try {
await Promise.all(ids.map((id) => moveMail(id, targetFolderId)));
setMails((prev) => prev.filter((m) => !selectedMailIds.has(m.id)));
setSelectedMailIds(new Set());
setShowMoveDropdown(false);
toast.success(t('mail.move'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[selectedMailIds, toast, t],
);
// Handle save draft (new or existing)
const handleSaveDraft = useCallback(
async (payload: MailDraftPayload) => {
try {
if (composeMode === 'draft' && draftMailState) {
await updateDraft(draftMailState.id, payload);
} else {
await saveDraft(payload);
}
toast.success(t('mail.draftSaved'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
throw err;
}
},
[composeMode, draftMailState, toast, t],
);
// Handle edit draft
const handleEditDraft = useCallback((mail: Mail) => {
setComposeMode('draft');
setDraftMailState(mail);
setReplyToMail(null);
setForwardMailState(null);
setComposeOpen(true);
}, []);
@@ -453,6 +554,32 @@ export function MailPage() {
),
onClick: () => selectedMail && handleCreateEvent(selectedMail),
},
{
id: 'delete',
plugin: 'mail',
label: t('mail.delete'),
group: 'mail-actions',
disabled: !hasMail,
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
),
onClick: () => selectedMail && handleDeleteMail(selectedMail),
},
{
id: 'move',
plugin: 'mail',
label: t('mail.move'),
group: 'mail-actions',
disabled: !hasMail,
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
),
onClick: () => setShowMoveDropdown((v) => !v),
},
{
id: 'sync',
plugin: 'mail',
@@ -492,11 +619,35 @@ export function MailPage() {
),
onClick: handleBulkMarkUnread,
},
{
id: 'bulk-delete',
plugin: 'mail',
label: t('mail.bulkDelete'),
group: 'bulk-actions',
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
),
onClick: handleBulkDelete,
},
{
id: 'bulk-move',
plugin: 'mail',
label: t('mail.bulkMove'),
group: 'bulk-actions',
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
),
onClick: () => setShowMoveDropdown((v) => !v),
},
);
}
registerItems('mail', items);
return () => unregisterPlugin('mail');
}, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleBulkMarkRead, handleBulkMarkUnread, t]);
}, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleDeleteMail, handleBulkDelete, handleBulkMarkRead, handleBulkMarkUnread, t]);
if (loadingAccounts) {
return (
@@ -589,6 +740,9 @@ export function MailPage() {
onToggleFlag={handleToggleFlag}
onDownloadAttachment={handleDownloadAttachment}
downloadingAttachmentId={downloadingAttachmentId}
onDelete={handleDeleteMail}
onMove={(mail) => setShowMoveDropdown(true)}
onEditDraft={handleEditDraft}
/>
</ResizablePanel>
</div>
@@ -667,6 +821,9 @@ export function MailPage() {
onToggleFlag={handleToggleFlag}
onDownloadAttachment={handleDownloadAttachment}
downloadingAttachmentId={downloadingAttachmentId}
onDelete={handleDeleteMail}
onMove={(mail) => setShowMoveDropdown(true)}
onEditDraft={handleEditDraft}
/>
</div>
)}
@@ -688,20 +845,66 @@ export function MailPage() {
<Button variant="secondary" size="sm" onClick={handleBulkMarkUnread}>
{t('mail.markUnread')}
</Button>
<Button variant="danger" size="sm" onClick={handleBulkDelete}>
{t('mail.bulkDelete')}
</Button>
<div className="relative">
<Button variant="secondary" size="sm" onClick={() => setShowMoveDropdown((v) => !v)}>
{t('mail.bulkMove')}
</Button>
{showMoveDropdown && (
<div className="absolute bottom-full mb-2 left-0 bg-white border border-secondary-200 rounded-lg shadow-lg max-h-60 overflow-y-auto min-w-48">
{folders.map((folder) => (
<button
key={folder.id}
onClick={() => handleBulkMove(folder.id)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-secondary-100 min-h-touch"
>
{folder.name}
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedMailIds(new Set())}>
</Button>
</div>
)}
{/* Move dropdown for single mail */}
{showMoveDropdown && selectedMail && selectedMailIds.size === 0 && (
<div
className="fixed bottom-4 right-4 z-50 bg-white border border-secondary-200 rounded-lg shadow-lg max-h-60 overflow-y-auto min-w-48"
data-testid="mail-move-dropdown"
>
<div className="px-3 py-2 text-xs font-medium text-secondary-500 border-b border-secondary-100">
{t('mail.moveTo')}
</div>
{folders
.filter((f) => f.account_id === selectedMail.account_id)
.map((folder) => (
<button
key={folder.id}
onClick={() => handleMoveMail(selectedMail, folder.id)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-secondary-100 min-h-touch"
>
{folder.name}
</button>
))}
</div>
)}
<ComposeModal
open={composeOpen}
mode={composeMode}
accountId={selectedAccountId}
replyToMail={replyToMail}
forwardMail={forwardMailState}
draftMail={draftMailState}
signatures={signatures}
onSend={handleSend}
onSaveDraft={handleSaveDraft}
onClose={() => setComposeOpen(false)}
/>
</div>