feat: mail UI improvements - folder mapping, display name, resize perf, list header

- Add configurable folder mapping (sent/drafts/spam/trash) per account
- Migration 0005_folder_mapping.sql with 4 new columns on mail_accounts
- Backend: send_mail uses account.sent_folder_imap_name if set
- Backend: _build_folder_hierarchy respects folder_mapping for is_standard
- Frontend: MailSettings shows folder mapping editor per account
- Frontend: MailSettings allows editing display_name for existing accounts
- Frontend: MailFolderTree shows display_name || email, removes MailIcon
- Frontend: ResizablePanel isDragging state with contain:strict + pointer-events:none
- Frontend: MailList merges select-all and sort controls into one line
This commit is contained in:
Agent Zero
2026-07-17 23:17:52 +02:00
parent 04c419402b
commit db433e81f1
9 changed files with 295 additions and 71 deletions
+49 -48
View File
@@ -91,56 +91,57 @@ export function MailList({
return (
<div className="flex flex-col h-full" data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
{/* Select-all header */}
<div className="flex items-center gap-2 px-3 py-2 md:px-4 md:py-2 border-b border-secondary-200 bg-secondary-50 flex-shrink-0">
<input
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('mail.selectAll')}
data-testid="mail-select-all"
/>
{selectedMailIds.size > 0 && (
<span className="text-xs text-secondary-600">
{t('mail.selectedCount', { count: selectedMailIds.size })}
</span>
{/* Select-all + Sort header in one row */}
<div className="flex items-center justify-between gap-2 px-3 py-2 md:px-4 md:py-2 border-b border-secondary-200 bg-secondary-50 flex-shrink-0">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('mail.selectAll')}
data-testid="mail-select-all"
/>
{selectedMailIds.size > 0 && (
<span className="text-xs text-secondary-600">
{t('mail.selectedCount', { count: selectedMailIds.size })}
</span>
)}
</div>
{onSortChange && (
<div className="flex items-center gap-2" data-testid="mail-sort-bar">
<label htmlFor="mail-sort-by" className="text-xs text-secondary-600">
{t('mail.sortBy')}:
</label>
<select
id="mail-sort-by"
value={sortBy}
onChange={handleSortFieldChange}
className="text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={t('mail.sortBy')}
data-testid="mail-sort-by"
>
<option value="date">{t('mail.sortDate')}</option>
<option value="from">{t('mail.sortFrom')}</option>
<option value="subject">{t('mail.sortSubject')}</option>
</select>
<button
onClick={handleSortOrderToggle}
className="inline-flex items-center gap-1 text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 hover:bg-secondary-100 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
title={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
data-testid="mail-sort-order"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
{sortOrder === 'asc'
? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />}
</svg>
<span>{sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}</span>
</button>
</div>
)}
</div>
{/* Sort header */}
{onSortChange && (
<div className="flex items-center gap-2 px-3 py-1.5 md:px-4 border-b border-secondary-200 bg-secondary-50 flex-shrink-0" data-testid="mail-sort-bar">
<label htmlFor="mail-sort-by" className="text-xs text-secondary-600">
{t('mail.sortBy')}:
</label>
<select
id="mail-sort-by"
value={sortBy}
onChange={handleSortFieldChange}
className="text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={t('mail.sortBy')}
data-testid="mail-sort-by"
>
<option value="date">{t('mail.sortDate')}</option>
<option value="from">{t('mail.sortFrom')}</option>
<option value="subject">{t('mail.sortSubject')}</option>
</select>
<button
onClick={handleSortOrderToggle}
className="inline-flex items-center gap-1 text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 hover:bg-secondary-100 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
title={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
data-testid="mail-sort-order"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
{sortOrder === 'asc'
? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />}
</svg>
<span>{sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}</span>
</button>
</div>
)}
{/* Mail list — scrolls, pagination stays fixed */}
<ul className="divide-y divide-secondary-100 flex-1 overflow-y-auto" role="list">
{mails.map((mail) => (