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 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
+109 -54
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
@@ -329,8 +354,6 @@ def _build_folder_hierarchy(
)
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,8 +362,11 @@ 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
@@ -16,6 +16,12 @@ import { EmptyState } from '@/components/ui/EmptyState';
*/
const FOLDER_NAME_MAP: Record<string, string> = {
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<string, string> = {
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',
};
/**
+13 -2
View File
@@ -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 (
<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}
data-testid={rest['data-testid']}
>
{children}
{/* Inner scrollable content area — scrollbar stays inside, never overlaps handle */}
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
{children}
</div>
{resizable && (
<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"
+3 -3
View File
@@ -442,7 +442,7 @@ export function MailPage() {
initialWidth={224}
minWidth={150}
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"
>
<div className="p-3">
@@ -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"
>
<MailList
@@ -479,7 +479,7 @@ export function MailPage() {
{/* Reading pane — flex-1, not resizable */}
<ResizablePanel
resizable={false}
className="overflow-y-auto bg-white"
className="bg-white"
data-testid="mail-detail-pane"
>
<MailDetail