fix: IMAP hierarchical folder sync, deduplication, folder tree display, resizable panel

This commit is contained in:
Agent Zero
2026-07-15 15:59:04 +02:00
parent e0975f4044
commit 57b6df5357
5 changed files with 146 additions and 70 deletions
+7 -4
View File
@@ -4,13 +4,14 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
# ─── Mail Accounts ─── # ─── Mail Accounts ───
class MailAccountCreate(BaseModel): 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) display_name: str = Field(default="", max_length=255)
imap_host: str = Field(..., min_length=1, max_length=255) imap_host: str = Field(..., min_length=1, max_length=255)
imap_port: int = Field(default=993, ge=1, le=65535) 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_host: str = Field(..., min_length=1, max_length=255)
smtp_port: int = Field(default=587, ge=1, le=65535) smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_tls: bool = True 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) password: str = Field(..., min_length=1, max_length=512)
is_shared: bool = False is_shared: bool = False
class MailAccountUpdate(BaseModel): 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) display_name: str | None = Field(None, max_length=255)
imap_host: str | None = Field(None, min_length=1, 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) imap_port: int | None = Field(None, ge=1, le=65535)
@@ -40,6 +42,7 @@ class MailAccountUpdate(BaseModel):
class MailAccountResponse(BaseModel): class MailAccountResponse(BaseModel):
id: str id: str
email: str
email_address: str email_address: str
display_name: str display_name: str
imap_host: str imap_host: str
+109 -54
View File
@@ -138,7 +138,7 @@ async def create_mail_account(
smtp_host=data["smtp_host"], smtp_host=data["smtp_host"],
smtp_port=data.get("smtp_port", 587), smtp_port=data.get("smtp_port", 587),
smtp_tls=data.get("smtp_tls", True), smtp_tls=data.get("smtp_tls", True),
username=data["username"], username=data.get("username") or data["email_address"],
encrypted_password=encrypt_password(data["password"]), encrypted_password=encrypt_password(data["password"]),
is_shared=data.get("is_shared", False), is_shared=data.get("is_shared", False),
is_active=True, is_active=True,
@@ -146,18 +146,29 @@ async def create_mail_account(
db.add(account) db.add(account)
await db.flush() 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 [ for fname, imap_name in [
("Posteingang", "INBOX"), ("Gesendet", "INBOX.Sent"),
("Postausgang", "Sent"), ("Entwürfe", "INBOX.Drafts"),
("Entwürfe", "Drafts"), ("Spam", "INBOX.spam"),
("Spam", "Spam"),
]: ]:
folder = MailFolder( folder = MailFolder(
tenant_id=tenant_id, tenant_id=tenant_id,
account_id=account.id, account_id=account.id,
name=fname, name=fname,
imap_name=imap_name, imap_name=imap_name,
parent_id=inbox_folder.id,
is_standard=True, is_standard=True,
) )
db.add(folder) db.add(folder)
@@ -167,21 +178,23 @@ async def create_mail_account(
async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict) -> MailAccount: async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict) -> MailAccount:
"""Update a mail account, encrypting password if changed.""" """Update a mail account, encrypting password if changed."""
for field in [ field_map = {
"email_address", "email": "email_address",
"display_name", "email_address": "email_address",
"imap_host", "display_name": "display_name",
"imap_port", "imap_host": "imap_host",
"imap_ssl", "imap_port": "imap_port",
"smtp_host", "imap_ssl": "imap_ssl",
"smtp_port", "smtp_host": "smtp_host",
"smtp_tls", "smtp_port": "smtp_port",
"username", "smtp_tls": "smtp_tls",
"is_shared", "username": "username",
"is_active", "is_shared": "is_shared",
]: "is_active": "is_active",
if field in data and data[field] is not None: }
setattr(account, field, data[field]) 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: if "password" in data and data["password"] is not None:
account.encrypted_password = encrypt_password(data["password"]) account.encrypted_password = encrypt_password(data["password"])
await db.flush() await db.flush()
@@ -198,6 +211,7 @@ def account_to_response(account: MailAccount) -> dict:
"""Convert MailAccount to response dict, NEVER including password.""" """Convert MailAccount to response dict, NEVER including password."""
return { return {
"id": str(account.id), "id": str(account.id),
"email": account.email_address,
"email_address": account.email_address, "email_address": account.email_address,
"display_name": account.display_name, "display_name": account.display_name,
"imap_host": account.imap_host, "imap_host": account.imap_host,
@@ -217,9 +231,17 @@ def account_to_response(account: MailAccount) -> dict:
# ─── IMAP Sync Service (F-MAIL-01) ─── # ─── 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 = { IMAP_FOLDER_NAME_MAP = {
"INBOX": "Posteingang", "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": "Gesendet",
"Sent Items": "Gesendet", "Sent Items": "Gesendet",
"Sent Mail": "Gesendet", "Sent Mail": "Gesendet",
@@ -232,14 +254,19 @@ IMAP_FOLDER_NAME_MAP = {
"Trash": "Papierkorb", "Trash": "Papierkorb",
"Deleted": "Papierkorb", "Deleted": "Papierkorb",
"Deleted Items": "Papierkorb", "Deleted Items": "Papierkorb",
"Archive": "Archiv",
} }
# Standard IMAP folders that are always considered "standard" # Standard IMAP folders that are always considered "standard"
STANDARD_IMAP_FOLDERS = { 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", "Drafts", "Draft",
"Spam", "Junk", "Junk Email", "Junk E-mail", "Spam", "Junk", "Junk Email", "Junk E-mail",
"Trash", "Deleted", "Deleted Items", "Trash", "Deleted", "Deleted Items",
"Archive",
} }
MAX_EMAILS_PER_FOLDER = 50 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.""" """Return the German display name for a standard IMAP folder, or the original name."""
if imap_name in IMAP_FOLDER_NAME_MAP: if imap_name in IMAP_FOLDER_NAME_MAP:
return IMAP_FOLDER_NAME_MAP[imap_name] 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 return imap_name
def _parse_imap_list_response(response) -> list[tuple[str, str]]: 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]] = [] folders: list[tuple[str, str]] = []
lines = response.lines if hasattr(response, 'lines') else response lines = response.lines if hasattr(response, 'lines') else response
for line in lines: for line in lines:
@@ -263,21 +299,17 @@ def _parse_imap_list_response(response) -> list[tuple[str, str]]:
text = line text = line
else: else:
continue continue
# IMAP LIST response format: * LIST (\HasChildren) "/" "INBOX"
# or: * LIST (\HasNoChildren) "/" "Sent"
if 'LIST' not in text: if 'LIST' not in text:
continue continue
# Extract the folder name — it's the last quoted segment # Extract quoted segments — the delimiter is the first quoted string,
# Split by the delimiter (usually " or ') # the folder name is the second.
parts = text.split('"') parts = text.split('"')
if len(parts) >= 4: if len(parts) >= 4:
# The delimiter is in parts[1], the folder name in parts[3]
delimiter = parts[1] delimiter = parts[1]
folder_name = parts[3] folder_name = parts[3]
flags = parts[0] if parts[0] else '' flags = parts[0] if parts[0] else ''
folders.append((flags, folder_name)) folders.append((flags, folder_name))
elif len(parts) >= 2: elif len(parts) >= 2:
# Fallback: try to extract the last quoted string
folder_name = parts[-2] if len(parts) >= 2 else '' folder_name = parts[-2] if len(parts) >= 2 else ''
if folder_name: if folder_name:
folders.append(('', folder_name)) folders.append(('', folder_name))
@@ -291,29 +323,22 @@ def _build_folder_hierarchy(
existing_folders: dict[str, MailFolder], existing_folders: dict[str, MailFolder],
) -> list[MailFolder]: ) -> list[MailFolder]:
"""Create or update MailFolder records from IMAP LIST response. """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] = [] result: list[MailFolder] = []
# Track delimiter for hierarchy parsing delimiter = '.'
delimiter = '/'
# First pass: create or update folder records
for flags, imap_name in imap_folders: for flags, imap_name in imap_folders:
if not imap_name: if not imap_name:
continue continue
# Determine German display name
display_name = _get_german_folder_name(imap_name) display_name = _get_german_folder_name(imap_name)
is_standard = imap_name in STANDARD_IMAP_FOLDERS 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: if imap_name in existing_folders:
folder = existing_folders[imap_name] folder = existing_folders[imap_name]
folder.name = display_name folder.name = display_name
@@ -329,8 +354,6 @@ def _build_folder_hierarchy(
) )
result.append(folder) result.append(folder)
# Set parent_id after all folders are created (second pass)
# Second pass: set parent_id based on IMAP hierarchy # Second pass: set parent_id based on IMAP hierarchy
folder_by_imap_name = {f.imap_name: f for f in result} folder_by_imap_name = {f.imap_name: f for f in result}
for folder in result: for folder in result:
@@ -339,8 +362,11 @@ def _build_folder_hierarchy(
parent_imap = delimiter.join(parts[:-1]) parent_imap = delimiter.join(parts[:-1])
if parent_imap in folder_by_imap_name: if parent_imap in folder_by_imap_name:
parent = folder_by_imap_name[parent_imap] parent = folder_by_imap_name[parent_imap]
if parent.id: folder.parent_id = parent.id # may be None for new folders; fixed after flush
folder.parent_id = parent.id else:
folder.parent_id = None
else:
folder.parent_id = None
return result return result
@@ -378,14 +404,14 @@ async def imap_sync_account(
list_response = await client.list('""', '"*"') list_response = await client.list('""', '"*"')
imap_folders = _parse_imap_list_response(list_response) 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: if not imap_folders:
imap_folders = [ imap_folders = [
('', 'INBOX'), ('', 'INBOX'),
('', 'Sent'), ('', 'INBOX.Sent'),
('', 'Drafts'), ('', 'INBOX.Drafts'),
('', 'Spam'), ('', 'INBOX.spam'),
('', 'Trash'), ('', 'INBOX.Trash'),
] ]
# 2) Load existing folders from DB for this account # 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 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 # 3) Create/update folders in DB
db_folders = _build_folder_hierarchy( db_folders = _build_folder_hierarchy(
imap_folders, account.id, tenant_id, existing_by_imap imap_folders, account.id, tenant_id, existing_by_imap
@@ -409,6 +449,20 @@ async def imap_sync_account(
db.add(folder) db.add(folder)
await db.flush() 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 # Build a map of imap_name -> folder_id for email sync
folder_by_imap = {f.imap_name: f for f in db_folders} 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) # 4) Sync emails for each folder (limit to last 50 per folder)
for imap_name, folder in folder_by_imap.items(): for imap_name, folder in folder_by_imap.items():
try: try:
# Select the folder on the IMAP server # Select the folder on the IMAP server — use the raw IMAP
select_resp = await client.select(f'"{imap_name}"') # name without extra quoting (aioimaplib handles it)
select_resp = await client.select(imap_name)
if select_resp.result != 'OK': if select_resp.result != 'OK':
continue continue
@@ -16,6 +16,12 @@ import { EmptyState } from '@/components/ui/EmptyState';
*/ */
const FOLDER_NAME_MAP: Record<string, string> = { const FOLDER_NAME_MAP: Record<string, string> = {
INBOX: 'Posteingang', INBOX: 'Posteingang',
'INBOX.Sent': 'Gesendet',
'INBOX.Drafts': 'Entwürfe',
'INBOX.Trash': 'Papierkorb',
'INBOX.Archive': 'Archiv',
'INBOX.spam': 'Spam',
'INBOX.Spam': 'Spam',
Sent: 'Gesendet', Sent: 'Gesendet',
Drafts: 'Entwürfe', Drafts: 'Entwürfe',
Spam: 'Spam', Spam: 'Spam',
@@ -23,11 +29,12 @@ const FOLDER_NAME_MAP: Record<string, string> = {
Trash: 'Papierkorb', Trash: 'Papierkorb',
'Sent Items': 'Gesendet', 'Sent Items': 'Gesendet',
'Sent Mail': 'Gesendet', 'Sent Mail': 'Gesendet',
'Draft': 'Entwürfe', Draft: 'Entwürfe',
'Deleted': 'Papierkorb', Deleted: 'Papierkorb',
'Deleted Items': 'Papierkorb', 'Deleted Items': 'Papierkorb',
'Junk Email': 'Spam', 'Junk Email': 'Spam',
'Junk E-mail': 'Spam', 'Junk E-mail': 'Spam',
Archive: 'Archiv',
}; };
/** /**
+12 -1
View File
@@ -1,6 +1,11 @@
/** /**
* ResizablePanel — a flex panel with drag-to-resize handle. * ResizablePanel — a flex panel with drag-to-resize handle.
* No external dependencies; uses React + mouse events only. * 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'; import React, { useState, useRef, useCallback, useEffect } from 'react';
@@ -77,11 +82,17 @@ export function ResizablePanel({
return ( return (
<div <div
className={clsx(resizable ? 'relative flex-shrink-0' : 'relative flex-1', className)} className={clsx(
resizable ? 'relative flex-shrink-0 flex flex-col' : 'relative flex-1 flex flex-col',
className,
)}
style={resizable ? { width: `${width}px` } : undefined} style={resizable ? { width: `${width}px` } : undefined}
data-testid={rest['data-testid']} data-testid={rest['data-testid']}
> >
{/* Inner scrollable content area — scrollbar stays inside, never overlaps handle */}
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
{children} {children}
</div>
{resizable && ( {resizable && (
<div <div
className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize bg-transparent hover:bg-primary-300 active:bg-primary-400 transition-colors z-50 pointer-events-auto" className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize bg-transparent hover:bg-primary-300 active:bg-primary-400 transition-colors z-50 pointer-events-auto"
+3 -3
View File
@@ -442,7 +442,7 @@ export function MailPage() {
initialWidth={224} initialWidth={224}
minWidth={150} minWidth={150}
maxWidth={400} maxWidth={400}
className="border-r border-secondary-200 overflow-y-auto bg-white" className="border-r border-secondary-200 bg-white"
data-testid="mail-folder-pane" data-testid="mail-folder-pane"
> >
<div className="p-3"> <div className="p-3">
@@ -461,7 +461,7 @@ export function MailPage() {
initialWidth={320} initialWidth={320}
minWidth={200} minWidth={200}
maxWidth={500} 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" data-testid="mail-list-pane"
> >
<MailList <MailList
@@ -479,7 +479,7 @@ export function MailPage() {
{/* Reading pane — flex-1, not resizable */} {/* Reading pane — flex-1, not resizable */}
<ResizablePanel <ResizablePanel
resizable={false} resizable={false}
className="overflow-y-auto bg-white" className="bg-white"
data-testid="mail-detail-pane" data-testid="mail-detail-pane"
> >
<MailDetail <MailDetail