feat(mail): resizable columns, account in folder tree, mail settings in CRM settings

- Add ResizablePanel component (components/ui/ResizablePanel.tsx) with
  drag-to-resize via mouse events, no external dependencies
- Replace fixed-width columns in Mail.tsx with ResizablePanel for all
  three panes (folder tree, mail list, mail detail)
- Remove account-select toolbar item from Mail.tsx; account selection
  is now integrated into the folder tree
- Restructure MailFolderTree to show mail accounts as top-level nodes
  with folders nested underneath, built from flat list via parent_id
- Add expand/collapse chevrons for accounts and folders with children
- Load folders for ALL accounts (not just selected one) so multi-account
  tree works
- Derive selectedAccountId from selected folder instead of separate
  account selector
- Add Mail tab to CRM Settings page (Settings.tsx) with 📧 icon
- Add /settings/mail route pointing to MailSettingsPage
- Update settings link in mail toolbar to point to /settings/mail
- Update no-accounts empty state link to /settings/mail
This commit is contained in:
Agent Zero
2026-07-15 13:37:22 +02:00
parent 5693fe1c3a
commit 0409a08002
5 changed files with 447 additions and 170 deletions
+198 -136
View File
@@ -1,5 +1,7 @@
/**
* Mail page — folder tree + mail list + reading pane + compose.
* Uses ResizablePanel for drag-to-resize columns.
* Account selection is integrated into the folder tree (left pane).
*/
import React, { useState, useEffect, useCallback } from 'react';
@@ -9,6 +11,7 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import { EmptyState } from '@/components/ui/EmptyState';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { MailFolderTree } from '@/components/mail/MailFolderTree';
import { MailList } from '@/components/mail/MailList';
import { MailDetail } from '@/components/mail/MailDetail';
@@ -89,7 +92,7 @@ export function MailPage() {
const sigs = await fetchSignatures();
setSignatures(sigs);
} catch {
// non-critical
// non-critical
}
}, []);
@@ -98,27 +101,35 @@ export function MailPage() {
loadSignatures();
}, [loadAccounts, loadSignatures]);
// Load folders when account changes
const loadFolders = useCallback(async () => {
if (!selectedAccountId) return;
// Load folders for ALL accounts
const loadAllFolders = useCallback(async () => {
if (accounts.length === 0) return;
setLoadingFolders(true);
try {
const folderList = await fetchFolders(selectedAccountId);
setFolders(folderList);
// Auto-select first folder
if (folderList.length > 0 && !selectedFolderId) {
setSelectedFolderId(folderList[0].id);
const allFolders: MailFolder[] = [];
for (const acc of accounts) {
const folderList = await fetchFolders(acc.id);
allFolders.push(...folderList);
}
setFolders(allFolders);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
}
setLoadingFolders(false);
}, [selectedAccountId, selectedFolderId]);
}, [accounts]);
useEffect(() => {
loadFolders();
}, [loadFolders]);
loadAllFolders();
}, [loadAllFolders]);
// Auto-select first folder when folders are loaded
useEffect(() => {
if (folders.length > 0 && !selectedFolderId) {
setSelectedFolderId(folders[0].id);
setSelectedAccountId(folders[0].account_id);
}
}, [folders, selectedFolderId]);
// Load mails when folder or page changes
const loadMails = useCallback(async () => {
@@ -146,32 +157,42 @@ export function MailPage() {
loadMails();
}, [loadMails]);
// Handle folder selection
const handleSelectFolder = useCallback((folderId: string) => {
setSelectedFolderId(folderId);
setMailsPage(1);
setSearchQuery('');
setSelectedMail(null);
}, []);
// Handle folder selection — derive account from folder
const handleSelectFolder = useCallback(
(folderId: string) => {
setSelectedFolderId(folderId);
const folder = folders.find((f) => f.id === folderId);
if (folder) {
setSelectedAccountId(folder.account_id);
}
setMailsPage(1);
setSearchQuery('');
setSelectedMail(null);
},
[folders],
);
// Handle mail selection
const handleSelectMail = useCallback(async (mail: Mail) => {
setLoadingMail(true);
try {
const detail = await getMail(mail.id);
setSelectedMail(detail);
// Mark as seen if not seen
if (!detail.is_seen) {
await updateFlags(mail.id, { seen: true });
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m)));
const handleSelectMail = useCallback(
async (mail: Mail) => {
setLoadingMail(true);
try {
const detail = await getMail(mail.id);
setSelectedMail(detail);
// Mark as seen if not seen
if (!detail.is_seen) {
await updateFlags(mail.id, { seen: true });
setMails((prev) => prev.map((m) => (m.id === mail.id ? { ...m, is_seen: true } : m)));
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
setSelectedMail(mail);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
setSelectedMail(mail);
}
setLoadingMail(false);
}, [toast]);
setLoadingMail(false);
},
[toast],
);
// Handle compose
const handleCompose = useCallback(() => {
@@ -198,72 +219,84 @@ export function MailPage() {
}, []);
// Handle toggle flag
const handleToggleFlag = useCallback(async (mail: Mail) => {
try {
await updateFlags(mail.id, { 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) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [toast]);
const handleToggleFlag = useCallback(
async (mail: Mail) => {
try {
await updateFlags(mail.id, { 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) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[toast],
);
// Handle create event from mail
const handleCreateEvent = useCallback(async (mail: Mail) => {
const title = mail.subject || t('mail.noSubject');
const now = new Date();
const start = now.toISOString();
const end = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
const payload: CreateEventFromMailPayload = {
title,
start,
end,
description: mail.body_text.slice(0, 500),
};
try {
await createEventFromMail(mail.id, payload);
toast.success(t('mail.eventCreated'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}, [toast, t]);
const handleCreateEvent = useCallback(
async (mail: Mail) => {
const title = mail.subject || t('mail.noSubject');
const now = new Date();
const start = now.toISOString();
const end = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
const payload: CreateEventFromMailPayload = {
title,
start,
end,
description: mail.body_text.slice(0, 500),
};
try {
await createEventFromMail(mail.id, payload);
toast.success(t('mail.eventCreated'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[toast, t],
);
// Handle attachment download
const handleDownloadAttachment = useCallback(async (mailId: string, attachment: MailAttachment) => {
setDownloadingAttachmentId(attachment.id);
try {
const blob = await downloadAttachment(mailId, attachment.id);
const url = window.URL.createObjectURL(blob);
const a = window.document.createElement('a');
a.href = url;
a.download = attachment.filename;
window.document.body.appendChild(a);
a.click();
window.document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setDownloadingAttachmentId(null);
}
}, [toast]);
const handleDownloadAttachment = useCallback(
async (mailId: string, attachment: MailAttachment) => {
setDownloadingAttachmentId(attachment.id);
try {
const blob = await downloadAttachment(mailId, attachment.id);
const url = window.URL.createObjectURL(blob);
const a = window.document.createElement('a');
a.href = url;
a.download = attachment.filename;
window.document.body.appendChild(a);
a.click();
window.document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setDownloadingAttachmentId(null);
}
},
[toast],
);
// Handle send (compose)
const handleSend = useCallback(async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => {
try {
if (mode === 'reply' && replyToMail) {
await replyMail(replyToMail.id, payload as ReplyPayload);
} else if (mode === 'forward' && forwardMailState) {
await forwardMail(forwardMailState.id, payload as ForwardPayload);
} else {
await sendMail(payload as SendMailPayload);
const handleSend = useCallback(
async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => {
try {
if (mode === 'reply' && replyToMail) {
await replyMail(replyToMail.id, payload as ReplyPayload);
} else if (mode === 'forward' && forwardMailState) {
await forwardMail(forwardMailState.id, payload as ForwardPayload);
} else {
await sendMail(payload as SendMailPayload);
}
toast.success(t('mail.sent'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
throw err;
}
toast.success(t('mail.sent'));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
throw err;
}
}, [replyToMail, forwardMailState, toast, t]);
},
[replyToMail, forwardMailState, toast, t],
);
// Handle search
const handleSearch = useCallback((query: string) => {
@@ -271,17 +304,7 @@ export function MailPage() {
setMailsPage(1);
}, []);
// Handle account switch
const handleAccountSwitch = useCallback((accountId: string) => {
setSelectedAccountId(accountId);
setSelectedFolderId(null);
setSelectedMail(null);
setMails([]);
setMailsPage(1);
setSearchQuery('');
}, []);
// Register toolbar items
// Register toolbar items (account-select removed — account is now in folder tree)
const registerItems = usePluginToolbarStore((s) => s.registerItems);
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
const selectedMailId = selectedMail?.id;
@@ -289,16 +312,6 @@ export function MailPage() {
useEffect(() => {
const hasMail = !!selectedMailId;
registerItems('mail', [
{
id: 'account-select',
plugin: 'mail',
label: 'Account',
type: 'select',
group: 'account',
selectValue: selectedAccountId,
selectOptions: accounts.map((a) => ({ value: a.id, label: a.email })),
onSelect: handleAccountSwitch,
},
{
id: 'search',
plugin: 'mail',
@@ -307,13 +320,18 @@ export function MailPage() {
group: 'search',
searchPlaceholder: 'E-Mails durchsuchen...',
onSearch: handleSearch,
onClick: () => {},
},
{
id: 'compose',
plugin: 'mail',
label: 'Verfassen',
group: 'compose',
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="M12 4v16m8-8H4" /></svg>,
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="M12 4v16m8-8H4" />
</svg>
),
onClick: handleCompose,
},
{
@@ -322,7 +340,11 @@ export function MailPage() {
label: 'Antworten',
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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /></svg>,
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 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
),
onClick: () => selectedMail && handleReply(selectedMail),
},
{
@@ -331,7 +353,11 @@ export function MailPage() {
label: 'Weiterleiten',
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="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" /></svg>,
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="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" />
</svg>
),
onClick: () => selectedMail && handleForward(selectedMail),
},
{
@@ -341,7 +367,11 @@ export function MailPage() {
group: 'mail-actions',
disabled: !hasMail,
active: selectedMailFlagged,
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 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" /></svg>,
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 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
),
onClick: () => selectedMail && handleToggleFlag(selectedMail),
},
{
@@ -350,7 +380,11 @@ export function MailPage() {
label: 'Termin',
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="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>,
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="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>
),
onClick: () => selectedMail && handleCreateEvent(selectedMail),
},
{
@@ -358,7 +392,11 @@ export function MailPage() {
plugin: 'mail',
label: 'Sync',
group: 'tools',
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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>,
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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
),
onClick: () => { /* sync trigger */ },
},
{
@@ -366,12 +404,19 @@ export function MailPage() {
plugin: 'mail',
label: 'Einstellungen',
group: 'tools',
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="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" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
onClick: () => window.location.href = '/mail/settings',
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="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" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
onClick: () => {
window.location.href = '/settings/mail';
},
},
]);
return () => unregisterPlugin('mail');
}, [accounts, selectedAccountId, selectedMailId, selectedMailFlagged, handleAccountSwitch, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]);
}, [selectedMailId, selectedMailFlagged, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]);
if (loadingAccounts) {
return (
@@ -391,7 +436,7 @@ export function MailPage() {
<EmptyState
title={t('mail.noAccounts')}
description={t('mail.noAccountsDesc')}
action={<Link to="/mail/settings" className="text-primary-600 hover:text-primary-700">{t('mail.configureAccount')}</Link>}
action={<Link to="/settings/mail" className="text-primary-600 hover:text-primary-700">{t('mail.configureAccount')}</Link>}
/>
</div>
);
@@ -405,23 +450,36 @@ export function MailPage() {
</div>
)}
{/* Three-pane layout */}
{/* Three-pane layout with resizable panels */}
<div className="flex flex-1 overflow-hidden">
{/* Folder tree */}
<div className="w-56 border-r border-secondary-200 overflow-y-auto bg-white" data-testid="mail-folder-pane">
{/* Folder tree — resizable */}
<ResizablePanel
initialWidth={224}
minWidth={150}
maxWidth={400}
className="border-r border-secondary-200 overflow-y-auto bg-white"
data-testid="mail-folder-pane"
>
<div className="p-3">
<h3 className="text-xs font-semibold text-secondary-500 uppercase mb-2">{t('mail.folders')}</h3>
<MailFolderTree
accounts={accounts}
folders={folders}
selectedFolderId={selectedFolderId}
onSelect={handleSelectFolder}
loading={loadingFolders}
/>
</div>
</div>
</ResizablePanel>
{/* Mail list */}
<div className="w-80 border-r border-secondary-200 overflow-y-auto bg-white" data-testid="mail-list-pane">
{/* Mail list — resizable */}
<ResizablePanel
initialWidth={320}
minWidth={200}
maxWidth={500}
className="border-r border-secondary-200 overflow-y-auto bg-white"
data-testid="mail-list-pane"
>
<MailList
mails={mails}
selectedMailId={selectedMail?.id || null}
@@ -432,10 +490,14 @@ export function MailPage() {
pageSize={PAGE_SIZE}
onPageChange={setMailsPage}
/>
</div>
</ResizablePanel>
{/* Reading pane */}
<div className="flex-1 overflow-y-auto bg-white" data-testid="mail-detail-pane">
{/* Reading pane — flex-1, not resizable */}
<ResizablePanel
resizable={false}
className="overflow-y-auto bg-white"
data-testid="mail-detail-pane"
>
<MailDetail
mail={selectedMail}
loading={loadingMail}
@@ -446,7 +508,7 @@ export function MailPage() {
onDownloadAttachment={handleDownloadAttachment}
downloadingAttachmentId={downloadingAttachmentId}
/>
</div>
</ResizablePanel>
</div>
<ComposeModal
@@ -461,4 +523,4 @@ export function MailPage() {
/>
</div>
);
}
}