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
+114 -59
View File
@@ -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