fix: IMAP hierarchical folder sync, deduplication, folder tree display, resizable panel
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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']}
|
||||||
>
|
>
|
||||||
{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 && (
|
{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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user