feat: mail phase 1 - HTML iframe display, bulk read/unread, IMAP flag sync
- Fix FlagUpdatePayload field names to match backend schema (is_seen/is_flagged) - Replace dangerouslySetInnerHTML with sandboxed iframe (srcDoc, sandbox="") - Add bulk mark read/unread with checkbox selection in MailList - Add floating bulk action bar in Mail.tsx - Add imap_sync_mail_flags() to push Seen/Flagged flags to IMAP server - Call IMAP flag sync in PATCH /flags endpoint - Add i18n keys for bulk actions (de/en)
This commit is contained in:
+126
-6
@@ -70,6 +70,7 @@ export function MailPage() {
|
||||
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());
|
||||
|
||||
// Load accounts
|
||||
const loadAccounts = useCallback(async () => {
|
||||
@@ -169,6 +170,7 @@ export function MailPage() {
|
||||
setMailsPage(1);
|
||||
setSearchQuery('');
|
||||
setSelectedMail(null);
|
||||
setSelectedMailIds(new Set());
|
||||
setActiveView('list');
|
||||
},
|
||||
[folders],
|
||||
@@ -184,7 +186,7 @@ export function MailPage() {
|
||||
setSelectedMail(detail);
|
||||
// Mark as seen if not seen
|
||||
if (!detail.is_seen) {
|
||||
await updateFlags(mail.id, { seen: true });
|
||||
await updateFlags(mail.id, { is_seen: true });
|
||||
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m)));
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -225,7 +227,7 @@ export function MailPage() {
|
||||
const handleToggleFlag = useCallback(
|
||||
async (mail: Mail) => {
|
||||
try {
|
||||
await updateFlags(mail.id, { flagged: !mail.is_flagged });
|
||||
await updateFlags(mail.id, { is_flagged: !mail.is_flagged });
|
||||
setSelectedMail((prev) => (prev ? { ...prev, is_flagged: !mail.is_flagged } : prev));
|
||||
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_flagged: !mail.is_flagged } : m)));
|
||||
} catch (err) {
|
||||
@@ -235,6 +237,66 @@ export function MailPage() {
|
||||
[toast],
|
||||
);
|
||||
|
||||
// Handle toggle select for bulk operations
|
||||
const handleToggleSelect = useCallback((mailId: string) => {
|
||||
setSelectedMailIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mailId)) {
|
||||
next.delete(mailId);
|
||||
} else {
|
||||
next.add(mailId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle select all mails on current page
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectedMailIds((prev) => {
|
||||
const allSelected = mails.length > 0 && mails.every((m) => prev.has(m.id));
|
||||
if (allSelected) {
|
||||
// Deselect all on current page
|
||||
const next = new Set(prev);
|
||||
for (const m of mails) {
|
||||
next.delete(m.id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
// Select all on current page
|
||||
const next = new Set(prev);
|
||||
for (const m of mails) {
|
||||
next.add(m.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [mails]);
|
||||
|
||||
// Handle bulk mark as read
|
||||
const handleBulkMarkRead = useCallback(async () => {
|
||||
const ids = Array.from(selectedMailIds);
|
||||
try {
|
||||
await Promise.all(ids.map((id) => updateFlags(id, { is_seen: true })));
|
||||
setMails((prev) => prev.map((m) => (selectedMailIds.has(m.id) ? { ...m, is_seen: true } : m)));
|
||||
setSelectedMailIds(new Set());
|
||||
toast.success(t('mail.markRead'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [selectedMailIds, toast, t]);
|
||||
|
||||
// Handle bulk mark as unread
|
||||
const handleBulkMarkUnread = useCallback(async () => {
|
||||
const ids = Array.from(selectedMailIds);
|
||||
try {
|
||||
await Promise.all(ids.map((id) => updateFlags(id, { is_seen: false })));
|
||||
setMails((prev) => prev.map((m) => (selectedMailIds.has(m.id) ? { ...m, is_seen: false } : m)));
|
||||
setSelectedMailIds(new Set());
|
||||
toast.success(t('mail.markUnread'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [selectedMailIds, toast, t]);
|
||||
|
||||
// Handle create event from mail
|
||||
const handleCreateEvent = useCallback(
|
||||
async (mail: Mail) => {
|
||||
@@ -314,12 +376,13 @@ export function MailPage() {
|
||||
const selectedMailFlagged = selectedMail?.is_flagged;
|
||||
useEffect(() => {
|
||||
const hasMail = !!selectedMailId;
|
||||
registerItems('mail', [
|
||||
const hasBulkSelection = selectedMailIds.size > 0;
|
||||
const items = [
|
||||
{
|
||||
id: 'search',
|
||||
plugin: 'mail',
|
||||
label: 'Suchen',
|
||||
type: 'search',
|
||||
type: 'search' as const,
|
||||
group: 'search',
|
||||
searchPlaceholder: 'E-Mails durchsuchen...',
|
||||
onSearch: handleSearch,
|
||||
@@ -402,9 +465,38 @@ export function MailPage() {
|
||||
),
|
||||
onClick: () => { /* sync trigger */ },
|
||||
},
|
||||
]);
|
||||
];
|
||||
if (hasBulkSelection) {
|
||||
items.push(
|
||||
{
|
||||
id: 'bulk-mark-read',
|
||||
plugin: 'mail',
|
||||
label: t('mail.markRead'),
|
||||
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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
onClick: handleBulkMarkRead,
|
||||
},
|
||||
{
|
||||
id: 'bulk-mark-unread',
|
||||
plugin: 'mail',
|
||||
label: t('mail.markUnread'),
|
||||
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="M3 19l6-6 4 4 8-8" />
|
||||
</svg>
|
||||
),
|
||||
onClick: handleBulkMarkUnread,
|
||||
},
|
||||
);
|
||||
}
|
||||
registerItems('mail', items);
|
||||
return () => unregisterPlugin('mail');
|
||||
}, [selectedMailId, selectedMailFlagged, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]);
|
||||
}, [selectedMailId, selectedMailFlagged, selectedMailIds, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent, handleBulkMarkRead, handleBulkMarkUnread, t]);
|
||||
|
||||
if (loadingAccounts) {
|
||||
return (
|
||||
@@ -476,6 +568,9 @@ export function MailPage() {
|
||||
total={mailsTotal}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setMailsPage}
|
||||
selectedMailIds={selectedMailIds}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -540,6 +635,9 @@ export function MailPage() {
|
||||
total={mailsTotal}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setMailsPage}
|
||||
selectedMailIds={selectedMailIds}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -574,6 +672,28 @@ export function MailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating bulk action bar */}
|
||||
{selectedMailIds.size > 0 && (
|
||||
<div
|
||||
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 px-4 py-2 bg-white border border-secondary-200 rounded-lg shadow-lg"
|
||||
data-testid="mail-bulk-action-bar"
|
||||
>
|
||||
<span className="text-sm text-secondary-600">
|
||||
{t('mail.selectedCount', { count: selectedMailIds.size })}
|
||||
</span>
|
||||
<div className="w-px h-5 bg-secondary-200" />
|
||||
<Button variant="secondary" size="sm" onClick={handleBulkMarkRead}>
|
||||
{t('mail.markRead')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleBulkMarkUnread}>
|
||||
{t('mail.markUnread')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedMailIds(new Set())}>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComposeModal
|
||||
open={composeOpen}
|
||||
mode={composeMode}
|
||||
|
||||
Reference in New Issue
Block a user