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:
@@ -61,6 +61,7 @@ export interface Mail {
|
||||
date: string;
|
||||
is_seen: boolean;
|
||||
is_flagged: boolean;
|
||||
is_draft: boolean;
|
||||
is_answered: boolean;
|
||||
has_attachments: boolean;
|
||||
attachments?: MailAttachment[];
|
||||
@@ -573,3 +574,31 @@ export function fetchLabels(): Promise<MailLabel[]> {
|
||||
export function deleteLabel(labelId: string): Promise<void> {
|
||||
return apiDelete<void>(`/mail/labels/${labelId}`);
|
||||
}
|
||||
|
||||
// ─── Delete / Move / Drafts ──────────────────────────────────────────────────
|
||||
|
||||
export function deleteMail(mailId: string): Promise<void> {
|
||||
return apiDelete<void>(`/mail/${mailId}`);
|
||||
}
|
||||
|
||||
export function moveMail(mailId: string, targetFolderId: string): Promise<Mail> {
|
||||
return apiPost<Mail>(`/mail/${mailId}/move`, { target_folder_id: targetFolderId });
|
||||
}
|
||||
|
||||
export interface MailDraftPayload {
|
||||
account_id: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body_text: string;
|
||||
body_html: string;
|
||||
}
|
||||
|
||||
export function saveDraft(payload: MailDraftPayload): Promise<Mail> {
|
||||
return apiPost<Mail>('/mail/drafts', payload);
|
||||
}
|
||||
|
||||
export function updateDraft(mailId: string, payload: MailDraftPayload): Promise<Mail> {
|
||||
return apiPatch<Mail>(`/mail/drafts/${mailId}`, payload);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -540,6 +540,19 @@
|
||||
"markUnread": "Als ungelesen markieren",
|
||||
"selectAll": "Alle auswählen",
|
||||
"bulkActions": "Sammelaktionen",
|
||||
"selectedCount": "{{count}} ausgewählt"
|
||||
"selectedCount": "{{count}} ausgewählt",
|
||||
"delete": "Löschen",
|
||||
"deleteConfirm": "Möchten Sie diese E-Mail wirklich löschen?",
|
||||
"deleteMail": "E-Mail löschen",
|
||||
"bulkDelete": "Alle löschen",
|
||||
"bulkDeleteConfirm": "Möchten Sie die ausgewählten E-Mails wirklich löschen?",
|
||||
"move": "Verschieben",
|
||||
"moveTo": "Verschieben nach",
|
||||
"moveMail": "E-Mail verschieben",
|
||||
"bulkMove": "Alle verschieben",
|
||||
"saveDraft": "Entwurf speichern",
|
||||
"editDraft": "Entwurf bearbeiten",
|
||||
"draftSaved": "Entwurf gespeichert.",
|
||||
"draft": "Entwurf"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +540,19 @@
|
||||
"markUnread": "Mark as Unread",
|
||||
"selectAll": "Select All",
|
||||
"bulkActions": "Bulk Actions",
|
||||
"selectedCount": "{{count}} selected"
|
||||
"selectedCount": "{{count}} selected",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Are you sure you want to delete this email?",
|
||||
"deleteMail": "Delete Email",
|
||||
"bulkDelete": "Delete All",
|
||||
"bulkDeleteConfirm": "Are you sure you want to delete the selected emails?",
|
||||
"move": "Move",
|
||||
"moveTo": "Move to",
|
||||
"moveMail": "Move Email",
|
||||
"bulkMove": "Move All",
|
||||
"saveDraft": "Save Draft",
|
||||
"editDraft": "Edit Draft",
|
||||
"draftSaved": "Draft saved.",
|
||||
"draft": "Draft"
|
||||
}
|
||||
}
|
||||
|
||||
+204
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user