diff --git a/app/plugins/builtins/mail/migrations/0005_folder_mapping.sql b/app/plugins/builtins/mail/migrations/0005_folder_mapping.sql new file mode 100644 index 0000000..a75652b --- /dev/null +++ b/app/plugins/builtins/mail/migrations/0005_folder_mapping.sql @@ -0,0 +1,6 @@ +-- Mail plugin migration 0005: add configurable IMAP folder mapping columns + +ALTER TABLE mail_accounts ADD COLUMN IF NOT EXISTS sent_folder_imap_name VARCHAR(255); +ALTER TABLE mail_accounts ADD COLUMN IF NOT EXISTS drafts_folder_imap_name VARCHAR(255); +ALTER TABLE mail_accounts ADD COLUMN IF NOT EXISTS spam_folder_imap_name VARCHAR(255); +ALTER TABLE mail_accounts ADD COLUMN IF NOT EXISTS trash_folder_imap_name VARCHAR(255); diff --git a/app/plugins/builtins/mail/models.py b/app/plugins/builtins/mail/models.py index f4f674a..3f32610 100644 --- a/app/plugins/builtins/mail/models.py +++ b/app/plugins/builtins/mail/models.py @@ -48,6 +48,10 @@ class MailAccount(Base, TenantMixin): encrypted_password: Mapped[str] = mapped_column(Text, nullable=False) is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + sent_folder_imap_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + drafts_folder_imap_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + spam_folder_imap_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + trash_folder_imap_name: Mapped[str | None] = mapped_column(String(255), nullable=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/app/plugins/builtins/mail/schemas.py b/app/plugins/builtins/mail/schemas.py index df815e2..c0e0813 100644 --- a/app/plugins/builtins/mail/schemas.py +++ b/app/plugins/builtins/mail/schemas.py @@ -22,6 +22,10 @@ class MailAccountCreate(BaseModel): username: str = Field(default="", min_length=0, max_length=255) password: str = Field(..., min_length=1, max_length=512) is_shared: bool = False + sent_folder_imap_name: str | None = Field(None, max_length=255) + drafts_folder_imap_name: str | None = Field(None, max_length=255) + spam_folder_imap_name: str | None = Field(None, max_length=255) + trash_folder_imap_name: str | None = Field(None, max_length=255) class MailAccountUpdate(BaseModel): @@ -38,6 +42,10 @@ class MailAccountUpdate(BaseModel): password: str | None = Field(None, min_length=1, max_length=512) is_shared: bool | None = None is_active: bool | None = None + sent_folder_imap_name: str | None = Field(None, max_length=255) + drafts_folder_imap_name: str | None = Field(None, max_length=255) + spam_folder_imap_name: str | None = Field(None, max_length=255) + trash_folder_imap_name: str | None = Field(None, max_length=255) class MailAccountResponse(BaseModel): @@ -54,6 +62,10 @@ class MailAccountResponse(BaseModel): username: str is_shared: bool is_active: bool + sent_folder_imap_name: str | None = None + drafts_folder_imap_name: str | None = None + spam_folder_imap_name: str | None = None + trash_folder_imap_name: str | None = None created_at: datetime | None = None updated_at: datetime | None = None diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index 9a3e5ec..91ab2c6 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -270,6 +270,10 @@ async def create_mail_account( encrypted_password=encrypt_password(data["password"]), is_shared=data.get("is_shared", False), is_active=True, + sent_folder_imap_name=data.get("sent_folder_imap_name"), + drafts_folder_imap_name=data.get("drafts_folder_imap_name"), + spam_folder_imap_name=data.get("spam_folder_imap_name"), + trash_folder_imap_name=data.get("trash_folder_imap_name"), ) db.add(account) await db.flush() @@ -319,6 +323,10 @@ async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict "username": "username", "is_shared": "is_shared", "is_active": "is_active", + "sent_folder_imap_name": "sent_folder_imap_name", + "drafts_folder_imap_name": "drafts_folder_imap_name", + "spam_folder_imap_name": "spam_folder_imap_name", + "trash_folder_imap_name": "trash_folder_imap_name", } for api_field, model_field in field_map.items(): if api_field in data and data[api_field] is not None: @@ -351,6 +359,10 @@ def account_to_response(account: MailAccount) -> dict: "username": account.username, "is_shared": account.is_shared, "is_active": account.is_active, + "sent_folder_imap_name": account.sent_folder_imap_name, + "drafts_folder_imap_name": account.drafts_folder_imap_name, + "spam_folder_imap_name": account.spam_folder_imap_name, + "trash_folder_imap_name": account.trash_folder_imap_name, "created_at": account.created_at, "updated_at": account.updated_at, } @@ -454,6 +466,7 @@ def _build_folder_hierarchy( tenant_id: uuid.UUID, existing_folders: dict[str, MailFolder], delimiter: str = '.', + folder_mapping: dict[str, str | None] | None = None, ) -> list[MailFolder]: """Create or update MailFolder records from IMAP LIST response. @@ -463,6 +476,13 @@ def _build_folder_hierarchy( """ result: list[MailFolder] = [] + # Build reverse mapping: imap_name -> standard type (sent/drafts/spam/trash) + mapping_by_imap: dict[str, str] = {} + if folder_mapping: + for std_type, imap_name_val in folder_mapping.items(): + if imap_name_val: + mapping_by_imap[imap_name_val] = std_type + # First pass: create or update folder records for flags, imap_name in imap_folders: if not imap_name: @@ -471,6 +491,19 @@ def _build_folder_hierarchy( display_name = _get_german_folder_name(imap_name) is_standard = imap_name in STANDARD_IMAP_FOLDERS + # If account has explicit folder mapping, mark mapped folders as standard + if imap_name in mapping_by_imap: + is_standard = True + std_type = mapping_by_imap[imap_name] + if std_type == "sent": + display_name = "Gesendet" + elif std_type == "drafts": + display_name = "Entwürfe" + elif std_type == "spam": + display_name = "Spam" + elif std_type == "trash": + display_name = "Papierkorb" + if imap_name in existing_folders: folder = existing_folders[imap_name] folder.name = display_name @@ -641,8 +674,15 @@ async def imap_sync_account( break # 3) Create/update folders in DB + folder_mapping = { + "sent": account.sent_folder_imap_name, + "drafts": account.drafts_folder_imap_name, + "spam": account.spam_folder_imap_name, + "trash": account.trash_folder_imap_name, + } db_folders = _build_folder_hierarchy( - imap_folders, account.id, tenant_id, existing_by_imap, imap_delimiter + imap_folders, account.id, tenant_id, existing_by_imap, imap_delimiter, + folder_mapping=folder_mapping, ) for folder in db_folders: if folder.id is None: @@ -1079,33 +1119,45 @@ async def send_mail_via_smtp( await smtp.send_message(msg, recipients=recipients) await smtp.quit() - # Store sent mail in Sent folder — flexible lookup to handle - # different IMAP naming conventions (Sent, INBOX.Sent, Sent Items, etc.) - sent_folder = ( - await db.execute( - select(MailFolder).where( - and_( - MailFolder.account_id == account.id, - MailFolder.imap_name.in_( - ["Sent", "INBOX.Sent", "Sent Items", "Sent Mail"] - ), - ) - ) - ) - ).scalar_one_or_none() - - if not sent_folder: + # Store sent mail in Sent folder — use configured mapping if set, + # otherwise flexible lookup to handle different IMAP naming conventions + if account.sent_folder_imap_name: sent_folder = ( await db.execute( select(MailFolder).where( and_( MailFolder.account_id == account.id, - MailFolder.is_standard == True, - MailFolder.imap_name.ilike("%sent%"), + MailFolder.imap_name == account.sent_folder_imap_name, ) ) ) ).scalar_one_or_none() + else: + sent_folder = ( + await db.execute( + select(MailFolder).where( + and_( + MailFolder.account_id == account.id, + MailFolder.imap_name.in_( + ["Sent", "INBOX.Sent", "Sent Items", "Sent Mail"] + ), + ) + ) + ) + ).scalar_one_or_none() + + if not sent_folder: + sent_folder = ( + await db.execute( + select(MailFolder).where( + and_( + MailFolder.account_id == account.id, + MailFolder.is_standard == True, + MailFolder.imap_name.ilike("%sent%"), + ) + ) + ) + ).scalar_one_or_none() if sent_folder: thread_id = _compute_thread_id(msg_id, references_header or "", in_reply_to) diff --git a/frontend/src/api/mail.ts b/frontend/src/api/mail.ts index 8b8167d..0360f8c 100644 --- a/frontend/src/api/mail.ts +++ b/frontend/src/api/mail.ts @@ -19,6 +19,10 @@ export interface MailAccount { smtp_port: number; is_shared: boolean; is_active: boolean; + sent_folder_imap_name?: string | null; + drafts_folder_imap_name?: string | null; + spam_folder_imap_name?: string | null; + trash_folder_imap_name?: string | null; created_at?: string | null; updated_at?: string | null; } @@ -195,6 +199,10 @@ export interface CreateAccountPayload { username?: string; password: string; is_shared?: boolean; + sent_folder_imap_name?: string | null; + drafts_folder_imap_name?: string | null; + spam_folder_imap_name?: string | null; + trash_folder_imap_name?: string | null; } export interface UpdateAccountPayload { @@ -205,6 +213,10 @@ export interface UpdateAccountPayload { smtp_port?: number; password?: string; is_active?: boolean; + sent_folder_imap_name?: string | null; + drafts_folder_imap_name?: string | null; + spam_folder_imap_name?: string | null; + trash_folder_imap_name?: string | null; } export interface CreateFolderPayload { diff --git a/frontend/src/components/mail/MailFolderTree.tsx b/frontend/src/components/mail/MailFolderTree.tsx index 3b656d3..491442a 100644 --- a/frontend/src/components/mail/MailFolderTree.tsx +++ b/frontend/src/components/mail/MailFolderTree.tsx @@ -207,8 +207,7 @@ function AccountNode({ data-testid={`account-${account.id}`} > - - {account.email} + {account.display_name || account.email} {expanded && (
diff --git a/frontend/src/components/mail/MailList.tsx b/frontend/src/components/mail/MailList.tsx index 26d9608..585f766 100644 --- a/frontend/src/components/mail/MailList.tsx +++ b/frontend/src/components/mail/MailList.tsx @@ -91,56 +91,57 @@ export function MailList({ return (
- {/* Select-all header */} -
- - {selectedMailIds.size > 0 && ( - - {t('mail.selectedCount', { count: selectedMailIds.size })} - + {/* Select-all + Sort header in one row */} +
+
+ + {selectedMailIds.size > 0 && ( + + {t('mail.selectedCount', { count: selectedMailIds.size })} + + )} +
+ {onSortChange && ( +
+ + + +
)}
- {/* Sort header */} - {onSortChange && ( -
- - - -
- )} {/* Mail list — scrolls, pagination stays fixed */}
    {mails.map((mail) => ( diff --git a/frontend/src/components/ui/ResizablePanel.tsx b/frontend/src/components/ui/ResizablePanel.tsx index 3186065..6214d81 100644 --- a/frontend/src/components/ui/ResizablePanel.tsx +++ b/frontend/src/components/ui/ResizablePanel.tsx @@ -38,6 +38,7 @@ export function ResizablePanel({ ...rest }: ResizablePanelProps) { const [width, setWidth] = useState(initialWidth); + const [isDragging, setIsDragging] = useState(false); const isResizing = useRef(false); const startX = useRef(0); const startWidth = useRef(0); @@ -47,6 +48,7 @@ export function ResizablePanel({ e.preventDefault(); e.stopPropagation(); isResizing.current = true; + setIsDragging(true); startX.current = e.clientX; startWidth.current = width; document.body.style.cursor = 'col-resize'; @@ -66,6 +68,7 @@ export function ResizablePanel({ const handleMouseUp = () => { if (isResizing.current) { isResizing.current = false; + setIsDragging(false); document.body.style.cursor = ''; document.body.style.userSelect = ''; } @@ -90,7 +93,14 @@ export function ResizablePanel({ data-testid={rest['data-testid']} > {/* Inner scrollable content area — scrollbar stays inside, never overlaps handle */} -
    +
    {children}
    {resizable && ( diff --git a/frontend/src/pages/MailSettings.tsx b/frontend/src/pages/MailSettings.tsx index 56dd6bc..efd73b5 100644 --- a/frontend/src/pages/MailSettings.tsx +++ b/frontend/src/pages/MailSettings.tsx @@ -21,7 +21,10 @@ import { deleteAccount, testConnection, triggerSync, + updateAccount, + fetchFolders, type MailAccount, + type MailFolder, type CreateAccountPayload, } from '@/api/mail'; @@ -47,6 +50,12 @@ export function MailSettingsPage() { const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(null); const [syncing, setSyncing] = useState(null); + const [editingDisplayName, setEditingDisplayName] = useState(null); + const [displayNameValue, setDisplayNameValue] = useState(''); + const [accountFolders, setAccountFolders] = useState>({}); + const [folderMappingEdit, setFolderMappingEdit] = useState(null); + const [folderMappingValues, setFolderMappingValues] = useState>({}); + const [savingMapping, setSavingMapping] = useState(false); const loadAccounts = useCallback(async () => { setLoading(true); @@ -134,6 +143,63 @@ export function MailSettingsPage() { } }, [toast, t]); + const handleSaveDisplayName = useCallback(async (accountId: string) => { + try { + const updated = await updateAccount(accountId, { display_name: displayNameValue }); + setAccounts((prev) => prev.map((a) => (a.id === accountId ? updated : a))); + setEditingDisplayName(null); + toast.success(t('common.saved')); + } catch (err: any) { + toast.error(err?.message || err?.detail || 'Save failed'); + } + }, [displayNameValue, toast, t]); + + const handleStartEditDisplayName = useCallback((acc: MailAccount) => { + setEditingDisplayName(acc.id); + setDisplayNameValue(acc.display_name || ''); + }, []); + + const loadAccountFolders = useCallback(async (accountId: string) => { + try { + const folders = await fetchFolders(accountId); + setAccountFolders((prev) => ({ ...prev, [accountId]: folders })); + } catch { + // non-critical + } + }, []); + + const handleStartFolderMapping = useCallback((acc: MailAccount) => { + setFolderMappingEdit(acc.id); + setFolderMappingValues({ + sent: acc.sent_folder_imap_name || '', + drafts: acc.drafts_folder_imap_name || '', + spam: acc.spam_folder_imap_name || '', + trash: acc.trash_folder_imap_name || '', + }); + if (!accountFolders[acc.id]) { + loadAccountFolders(acc.id); + } + }, [accountFolders, loadAccountFolders]); + + const handleSaveFolderMapping = useCallback(async (accountId: string) => { + setSavingMapping(true); + try { + const payload: Record = {}; + payload.sent_folder_imap_name = folderMappingValues.sent || null; + payload.drafts_folder_imap_name = folderMappingValues.drafts || null; + payload.spam_folder_imap_name = folderMappingValues.spam || null; + payload.trash_folder_imap_name = folderMappingValues.trash || null; + const updated = await updateAccount(accountId, payload); + setAccounts((prev) => prev.map((a) => (a.id === accountId ? updated : a))); + setFolderMappingEdit(null); + toast.success(t('common.saved')); + } catch (err: any) { + toast.error(err?.message || err?.detail || 'Save failed'); + } finally { + setSavingMapping(false); + } + }, [folderMappingValues, toast, t]); + const activeTab = searchParams.get('tab') || 'accounts'; const tabs = [ @@ -238,7 +304,32 @@ export function MailSettingsPage() {
    -

    {acc.display_name || acc.email}

    + {editingDisplayName === acc.id ? ( +
    + setDisplayNameValue(e.target.value)} + placeholder={acc.email} + className="flex-1" + /> + + +
    + ) : ( +
    +

    {acc.display_name || acc.email}

    + +
    + )}

    {acc.email}

    {t('mail.serverInfo')}: {acc.imap_host}:{acc.imap_port} / {acc.smtp_host}:{acc.smtp_port} @@ -277,6 +368,43 @@ export function MailSettingsPage() {

    + {/* Folder mapping section */} + {folderMappingEdit === acc.id ? ( +
    +

    Ordner-Zuordnung

    + {(['sent', 'drafts', 'spam', 'trash'] as const).map((type) => ( +
    + + +
    + ))} +
    + + +
    +
    + ) : ( +
    + +
    + )}
    ))}