From 57b6df5357778b517d7de4bf2fc5a04a5746de75 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 15 Jul 2026 15:59:04 +0200 Subject: [PATCH] fix: IMAP hierarchical folder sync, deduplication, folder tree display, resizable panel --- app/plugins/builtins/mail/schemas.py | 11 +- app/plugins/builtins/mail/services.py | 173 ++++++++++++------ .../src/components/mail/MailFolderTree.tsx | 11 +- frontend/src/components/ui/ResizablePanel.tsx | 15 +- frontend/src/pages/Mail.tsx | 6 +- 5 files changed, 146 insertions(+), 70 deletions(-) diff --git a/app/plugins/builtins/mail/schemas.py b/app/plugins/builtins/mail/schemas.py index b3e5853..a3970d3 100644 --- a/app/plugins/builtins/mail/schemas.py +++ b/app/plugins/builtins/mail/schemas.py @@ -4,13 +4,14 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field # ─── Mail Accounts ─── class MailAccountCreate(BaseModel): - email_address: str = Field(..., min_length=1, max_length=255) + model_config = ConfigDict(populate_by_name=True) + email_address: str = Field(..., min_length=1, max_length=255, alias="email") display_name: str = Field(default="", max_length=255) imap_host: str = Field(..., min_length=1, max_length=255) imap_port: int = Field(default=993, ge=1, le=65535) @@ -18,13 +19,14 @@ class MailAccountCreate(BaseModel): smtp_host: str = Field(..., min_length=1, max_length=255) smtp_port: int = Field(default=587, ge=1, le=65535) smtp_tls: bool = True - username: str = Field(..., min_length=1, max_length=255) + username: str = Field(default="", min_length=0, max_length=255) password: str = Field(..., min_length=1, max_length=512) is_shared: bool = False class MailAccountUpdate(BaseModel): - email_address: str | None = Field(None, min_length=1, max_length=255) + model_config = ConfigDict(populate_by_name=True) + email_address: str | None = Field(None, min_length=1, max_length=255, alias="email") display_name: str | None = Field(None, max_length=255) imap_host: str | None = Field(None, min_length=1, max_length=255) imap_port: int | None = Field(None, ge=1, le=65535) @@ -40,6 +42,7 @@ class MailAccountUpdate(BaseModel): class MailAccountResponse(BaseModel): id: str + email: str email_address: str display_name: str imap_host: str diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index 6b5a577..c80fd4a 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -138,7 +138,7 @@ async def create_mail_account( smtp_host=data["smtp_host"], smtp_port=data.get("smtp_port", 587), smtp_tls=data.get("smtp_tls", True), - username=data["username"], + username=data.get("username") or data["email_address"], encrypted_password=encrypt_password(data["password"]), is_shared=data.get("is_shared", False), is_active=True, @@ -146,18 +146,29 @@ async def create_mail_account( db.add(account) await db.flush() - # Create standard folders + # Create INBOX first so subfolders can reference it as parent + inbox_folder = MailFolder( + tenant_id=tenant_id, + account_id=account.id, + name="Posteingang", + imap_name="INBOX", + is_standard=True, + ) + db.add(inbox_folder) + await db.flush() + + # Create standard subfolders under INBOX (IMAP server uses '.' delimiter) for fname, imap_name in [ - ("Posteingang", "INBOX"), - ("Postausgang", "Sent"), - ("Entwürfe", "Drafts"), - ("Spam", "Spam"), + ("Gesendet", "INBOX.Sent"), + ("Entwürfe", "INBOX.Drafts"), + ("Spam", "INBOX.spam"), ]: folder = MailFolder( tenant_id=tenant_id, account_id=account.id, name=fname, imap_name=imap_name, + parent_id=inbox_folder.id, is_standard=True, ) db.add(folder) @@ -167,21 +178,23 @@ async def create_mail_account( async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict) -> MailAccount: """Update a mail account, encrypting password if changed.""" - for field in [ - "email_address", - "display_name", - "imap_host", - "imap_port", - "imap_ssl", - "smtp_host", - "smtp_port", - "smtp_tls", - "username", - "is_shared", - "is_active", - ]: - if field in data and data[field] is not None: - setattr(account, field, data[field]) + field_map = { + "email": "email_address", + "email_address": "email_address", + "display_name": "display_name", + "imap_host": "imap_host", + "imap_port": "imap_port", + "imap_ssl": "imap_ssl", + "smtp_host": "smtp_host", + "smtp_port": "smtp_port", + "smtp_tls": "smtp_tls", + "username": "username", + "is_shared": "is_shared", + "is_active": "is_active", + } + for api_field, model_field in field_map.items(): + if api_field in data and data[api_field] is not None: + setattr(account, model_field, data[api_field]) if "password" in data and data["password"] is not None: account.encrypted_password = encrypt_password(data["password"]) await db.flush() @@ -198,6 +211,7 @@ def account_to_response(account: MailAccount) -> dict: """Convert MailAccount to response dict, NEVER including password.""" return { "id": str(account.id), + "email": account.email_address, "email_address": account.email_address, "display_name": account.display_name, "imap_host": account.imap_host, @@ -217,9 +231,17 @@ def account_to_response(account: MailAccount) -> dict: # ─── IMAP Sync Service (F-MAIL-01) ─── -# German display names for standard IMAP folders +# German display names for standard IMAP folders. +# Keys are full IMAP paths (with dot delimiter) and also bare leaf names. IMAP_FOLDER_NAME_MAP = { "INBOX": "Posteingang", + "INBOX.Sent": "Gesendet", + "INBOX.Drafts": "Entwürfe", + "INBOX.Trash": "Papierkorb", + "INBOX.Archive": "Archiv", + "INBOX.spam": "Spam", + "INBOX.Spam": "Spam", + # Bare names (fallback for servers that don't nest under INBOX) "Sent": "Gesendet", "Sent Items": "Gesendet", "Sent Mail": "Gesendet", @@ -232,14 +254,19 @@ IMAP_FOLDER_NAME_MAP = { "Trash": "Papierkorb", "Deleted": "Papierkorb", "Deleted Items": "Papierkorb", + "Archive": "Archiv", } # Standard IMAP folders that are always considered "standard" STANDARD_IMAP_FOLDERS = { - "INBOX", "Sent", "Sent Items", "Sent Mail", + "INBOX", + "INBOX.Sent", "INBOX.Drafts", "INBOX.Trash", "INBOX.Archive", + "INBOX.spam", "INBOX.Spam", + "Sent", "Sent Items", "Sent Mail", "Drafts", "Draft", "Spam", "Junk", "Junk Email", "Junk E-mail", "Trash", "Deleted", "Deleted Items", + "Archive", } MAX_EMAILS_PER_FOLDER = 50 @@ -249,11 +276,20 @@ def _get_german_folder_name(imap_name: str) -> str: """Return the German display name for a standard IMAP folder, or the original name.""" if imap_name in IMAP_FOLDER_NAME_MAP: return IMAP_FOLDER_NAME_MAP[imap_name] + # Try the leaf component (after last dot) for unknown nested folders + leaf = imap_name.rsplit(".", 1)[-1] if "." in imap_name else imap_name + if leaf in IMAP_FOLDER_NAME_MAP: + return IMAP_FOLDER_NAME_MAP[leaf] return imap_name def _parse_imap_list_response(response) -> list[tuple[str, str]]: - """Parse IMAP LIST response into list of (flags, folder_name) tuples.""" + """Parse IMAP LIST response into list of (flags, folder_name) tuples. + + Handles both "/" and "." delimiters. The IMAP LIST response format is: + * LIST (\\HasChildren) "." "INBOX" + * LIST (\\HasNoChildren) "." "INBOX.Sent" + """ folders: list[tuple[str, str]] = [] lines = response.lines if hasattr(response, 'lines') else response for line in lines: @@ -263,21 +299,17 @@ def _parse_imap_list_response(response) -> list[tuple[str, str]]: text = line else: continue - # IMAP LIST response format: * LIST (\HasChildren) "/" "INBOX" - # or: * LIST (\HasNoChildren) "/" "Sent" if 'LIST' not in text: continue - # Extract the folder name — it's the last quoted segment - # Split by the delimiter (usually " or ') + # Extract quoted segments — the delimiter is the first quoted string, + # the folder name is the second. parts = text.split('"') if len(parts) >= 4: - # The delimiter is in parts[1], the folder name in parts[3] delimiter = parts[1] folder_name = parts[3] flags = parts[0] if parts[0] else '' folders.append((flags, folder_name)) elif len(parts) >= 2: - # Fallback: try to extract the last quoted string folder_name = parts[-2] if len(parts) >= 2 else '' if folder_name: folders.append(('', folder_name)) @@ -291,29 +323,22 @@ def _build_folder_hierarchy( existing_folders: dict[str, MailFolder], ) -> list[MailFolder]: """Create or update MailFolder records from IMAP LIST response. - Returns the list of folders to add/update. + + Handles dot-delimited hierarchies (e.g. INBOX.Sent → parent=INBOX). + Updates existing folders in-place (name, is_standard, parent_id) so + that stale DB records with wrong imap_name values get corrected. """ result: list[MailFolder] = [] - # Track delimiter for hierarchy parsing - delimiter = '/' - + delimiter = '.' + + # First pass: create or update folder records for flags, imap_name in imap_folders: if not imap_name: continue - - # Determine German display name + display_name = _get_german_folder_name(imap_name) is_standard = imap_name in STANDARD_IMAP_FOLDERS - - # Parse parent from hierarchy (e.g. "INBOX/Subfolder") - parent_imap_name = None - local_name = imap_name - if delimiter in imap_name: - parts = imap_name.split(delimiter) - local_name = parts[-1] - parent_imap_name = delimiter.join(parts[:-1]) - - # Check if folder already exists + if imap_name in existing_folders: folder = existing_folders[imap_name] folder.name = display_name @@ -328,9 +353,7 @@ def _build_folder_hierarchy( is_standard=is_standard, ) result.append(folder) - - # Set parent_id after all folders are created (second pass) - + # Second pass: set parent_id based on IMAP hierarchy folder_by_imap_name = {f.imap_name: f for f in result} for folder in result: @@ -339,9 +362,12 @@ def _build_folder_hierarchy( parent_imap = delimiter.join(parts[:-1]) if parent_imap in folder_by_imap_name: parent = folder_by_imap_name[parent_imap] - if parent.id: - folder.parent_id = parent.id - + folder.parent_id = parent.id # may be None for new folders; fixed after flush + else: + folder.parent_id = None + else: + folder.parent_id = None + return result @@ -378,14 +404,14 @@ async def imap_sync_account( list_response = await client.list('""', '"*"') imap_folders = _parse_imap_list_response(list_response) - # If LIST returned nothing, fall back to standard folders + # If LIST returned nothing, fall back to standard folders (dot-delimited) if not imap_folders: imap_folders = [ ('', 'INBOX'), - ('', 'Sent'), - ('', 'Drafts'), - ('', 'Spam'), - ('', 'Trash'), + ('', 'INBOX.Sent'), + ('', 'INBOX.Drafts'), + ('', 'INBOX.spam'), + ('', 'INBOX.Trash'), ] # 2) Load existing folders from DB for this account @@ -400,6 +426,20 @@ async def imap_sync_account( f.imap_name: f for f in existing_db_folders } + # 2a) Migrate stale folder names: if DB has 'Sent' but IMAP returns + # 'INBOX.Sent', update the DB record's imap_name so it matches. + imap_names_from_server = {name for _, name in imap_folders if name} + for db_folder in existing_db_folders: + if db_folder.imap_name not in imap_names_from_server: + # Try matching by leaf component + leaf = db_folder.imap_name.rsplit(".", 1)[-1] + for srv_name in imap_names_from_server: + srv_leaf = srv_name.rsplit(".", 1)[-1] + if srv_leaf.lower() == leaf.lower(): + db_folder.imap_name = srv_name + existing_by_imap[srv_name] = db_folder + break + # 3) Create/update folders in DB db_folders = _build_folder_hierarchy( imap_folders, account.id, tenant_id, existing_by_imap @@ -409,6 +449,20 @@ async def imap_sync_account( db.add(folder) await db.flush() + # 3a) Re-set parent_id now that new folders have IDs after flush + delimiter = '.' + folder_by_imap_post_flush = {f.imap_name: f for f in db_folders} + for folder in db_folders: + if delimiter in folder.imap_name: + parts = folder.imap_name.split(delimiter) + parent_imap = delimiter.join(parts[:-1]) + if parent_imap in folder_by_imap_post_flush: + parent = folder_by_imap_post_flush[parent_imap] + if parent.id: + folder.parent_id = parent.id + + await db.flush() + # Build a map of imap_name -> folder_id for email sync folder_by_imap = {f.imap_name: f for f in db_folders} @@ -417,8 +471,9 @@ async def imap_sync_account( # 4) Sync emails for each folder (limit to last 50 per folder) for imap_name, folder in folder_by_imap.items(): try: - # Select the folder on the IMAP server - select_resp = await client.select(f'"{imap_name}"') + # Select the folder on the IMAP server — use the raw IMAP + # name without extra quoting (aioimaplib handles it) + select_resp = await client.select(imap_name) if select_resp.result != 'OK': continue diff --git a/frontend/src/components/mail/MailFolderTree.tsx b/frontend/src/components/mail/MailFolderTree.tsx index 0fc2143..401ccea 100644 --- a/frontend/src/components/mail/MailFolderTree.tsx +++ b/frontend/src/components/mail/MailFolderTree.tsx @@ -16,6 +16,12 @@ import { EmptyState } from '@/components/ui/EmptyState'; */ const FOLDER_NAME_MAP: Record = { INBOX: 'Posteingang', + 'INBOX.Sent': 'Gesendet', + 'INBOX.Drafts': 'Entwürfe', + 'INBOX.Trash': 'Papierkorb', + 'INBOX.Archive': 'Archiv', + 'INBOX.spam': 'Spam', + 'INBOX.Spam': 'Spam', Sent: 'Gesendet', Drafts: 'Entwürfe', Spam: 'Spam', @@ -23,11 +29,12 @@ const FOLDER_NAME_MAP: Record = { Trash: 'Papierkorb', 'Sent Items': 'Gesendet', 'Sent Mail': 'Gesendet', - 'Draft': 'Entwürfe', - 'Deleted': 'Papierkorb', + Draft: 'Entwürfe', + Deleted: 'Papierkorb', 'Deleted Items': 'Papierkorb', 'Junk Email': 'Spam', 'Junk E-mail': 'Spam', + Archive: 'Archiv', }; /** diff --git a/frontend/src/components/ui/ResizablePanel.tsx b/frontend/src/components/ui/ResizablePanel.tsx index 345dadd..3186065 100644 --- a/frontend/src/components/ui/ResizablePanel.tsx +++ b/frontend/src/components/ui/ResizablePanel.tsx @@ -1,6 +1,11 @@ /** * ResizablePanel — a flex panel with drag-to-resize handle. * No external dependencies; uses React + mouse events only. + * + * The panel renders an outer fixed-width container with the resize handle + * positioned absolutely on the right edge (z-index above scrollbars). + * Scrollable content goes into an inner div so that the scrollbar never + * overlaps the drag handle. */ import React, { useState, useRef, useCallback, useEffect } from 'react'; @@ -77,11 +82,17 @@ export function ResizablePanel({ return (
- {children} + {/* Inner scrollable content area — scrollbar stays inside, never overlaps handle */} +
+ {children} +
{resizable && (
@@ -461,7 +461,7 @@ export function MailPage() { initialWidth={320} minWidth={200} maxWidth={500} - className="border-r border-secondary-200 overflow-y-auto bg-white" + className="border-r border-secondary-200 bg-white" data-testid="mail-list-pane" >