fix: mail UI German labels, multi-folder sync, account management, resizable panel fix
This commit is contained in:
@@ -217,6 +217,134 @@ def account_to_response(account: MailAccount) -> dict:
|
||||
# ─── IMAP Sync Service (F-MAIL-01) ───
|
||||
|
||||
|
||||
# German display names for standard IMAP folders
|
||||
IMAP_FOLDER_NAME_MAP = {
|
||||
"INBOX": "Posteingang",
|
||||
"Sent": "Gesendet",
|
||||
"Sent Items": "Gesendet",
|
||||
"Sent Mail": "Gesendet",
|
||||
"Drafts": "Entwürfe",
|
||||
"Draft": "Entwürfe",
|
||||
"Spam": "Spam",
|
||||
"Junk": "Spam",
|
||||
"Junk Email": "Spam",
|
||||
"Junk E-mail": "Spam",
|
||||
"Trash": "Papierkorb",
|
||||
"Deleted": "Papierkorb",
|
||||
"Deleted Items": "Papierkorb",
|
||||
}
|
||||
|
||||
# Standard IMAP folders that are always considered "standard"
|
||||
STANDARD_IMAP_FOLDERS = {
|
||||
"INBOX", "Sent", "Sent Items", "Sent Mail",
|
||||
"Drafts", "Draft",
|
||||
"Spam", "Junk", "Junk Email", "Junk E-mail",
|
||||
"Trash", "Deleted", "Deleted Items",
|
||||
}
|
||||
|
||||
MAX_EMAILS_PER_FOLDER = 50
|
||||
|
||||
|
||||
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]
|
||||
return imap_name
|
||||
|
||||
|
||||
def _parse_imap_list_response(response) -> list[tuple[str, str]]:
|
||||
"""Parse IMAP LIST response into list of (flags, folder_name) tuples."""
|
||||
folders: list[tuple[str, str]] = []
|
||||
lines = response.lines if hasattr(response, 'lines') else response
|
||||
for line in lines:
|
||||
if isinstance(line, (bytes, bytearray)):
|
||||
text = line.decode('utf-8', errors='replace')
|
||||
elif isinstance(line, 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 ')
|
||||
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))
|
||||
return folders
|
||||
|
||||
|
||||
def _build_folder_hierarchy(
|
||||
imap_folders: list[tuple[str, str]],
|
||||
account_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
existing_folders: dict[str, MailFolder],
|
||||
) -> list[MailFolder]:
|
||||
"""Create or update MailFolder records from IMAP LIST response.
|
||||
Returns the list of folders to add/update.
|
||||
"""
|
||||
result: list[MailFolder] = []
|
||||
# Track delimiter for hierarchy parsing
|
||||
delimiter = '/'
|
||||
|
||||
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
|
||||
folder.is_standard = is_standard
|
||||
result.append(folder)
|
||||
else:
|
||||
folder = MailFolder(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
name=display_name,
|
||||
imap_name=imap_name,
|
||||
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:
|
||||
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_name:
|
||||
parent = folder_by_imap_name[parent_imap]
|
||||
if parent.id:
|
||||
folder.parent_id = parent.id
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def imap_sync_account(
|
||||
db: AsyncSession,
|
||||
account_id: uuid.UUID,
|
||||
@@ -224,8 +352,10 @@ async def imap_sync_account(
|
||||
) -> dict:
|
||||
"""Sync mail folders and messages from IMAP server.
|
||||
|
||||
This function is designed to be called as an ARQ background job.
|
||||
In tests it is mocked — real implementation connects via aioimaplib.
|
||||
Syncs ALL folders from the IMAP server (not just INBOX).
|
||||
For each folder, fetches the last MAX_EMAILS_PER_FOLDER emails
|
||||
(sorted by date descending) to avoid timeouts on large mailboxes.
|
||||
Creates/updates mail_folders records with German display names.
|
||||
"""
|
||||
account = (
|
||||
await db.execute(
|
||||
@@ -239,146 +369,208 @@ async def imap_sync_account(
|
||||
|
||||
password = await get_account_password(account)
|
||||
|
||||
# Import aioimaplib here so tests can mock it
|
||||
try:
|
||||
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
||||
await client.wait_hello_from_server()
|
||||
await client.login(account.username, password)
|
||||
await client.select("INBOX")
|
||||
|
||||
# Fetch all message UIDs
|
||||
response = await client.uid_search("ALL")
|
||||
uids = response[1][0].split() if response[1] and response[1][0] else []
|
||||
# 1) LIST all folders from IMAP server
|
||||
list_response = await client.list('""', '"*"')
|
||||
imap_folders = _parse_imap_list_response(list_response)
|
||||
|
||||
synced_count = 0
|
||||
for uid in uids:
|
||||
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
fetch_resp = await client.uid('fetch', uid_str, '(RFC822)')
|
||||
raw_email = None
|
||||
# aioimaplib Response lines: [header_bytes, data_bytearray, closing_bytes, completion_bytes]
|
||||
for line in (fetch_resp.lines if hasattr(fetch_resp, 'lines') else fetch_resp):
|
||||
if isinstance(line, bytearray):
|
||||
raw_email = bytes(line)
|
||||
break
|
||||
if raw_email is None:
|
||||
continue
|
||||
if isinstance(raw_email, str):
|
||||
raw_email = raw_email.encode()
|
||||
# If LIST returned nothing, fall back to standard folders
|
||||
if not imap_folders:
|
||||
imap_folders = [
|
||||
('', 'INBOX'),
|
||||
('', 'Sent'),
|
||||
('', 'Drafts'),
|
||||
('', 'Spam'),
|
||||
('', 'Trash'),
|
||||
]
|
||||
|
||||
msg = message_from_bytes(raw_email)
|
||||
body_text = ""
|
||||
body_html = ""
|
||||
attachments = []
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
if ct == "text/plain":
|
||||
body_text = part.get_payload(decode=True).decode("utf-8", errors="replace")
|
||||
elif ct == "text/html":
|
||||
body_html = part.get_payload(decode=True).decode("utf-8", errors="replace")
|
||||
elif part.get_filename():
|
||||
attachments.append(
|
||||
{
|
||||
"filename": part.get_filename(),
|
||||
"mime_type": ct,
|
||||
"size": len(part.get_payload(decode=True) or b""),
|
||||
}
|
||||
)
|
||||
else:
|
||||
ct = msg.get_content_type()
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
decoded = payload.decode("utf-8", errors="replace")
|
||||
if ct == "text/html":
|
||||
body_html = decoded
|
||||
else:
|
||||
body_text = decoded
|
||||
|
||||
message_id = msg.get("Message-ID", make_msgid())
|
||||
subject = msg.get("Subject", "")
|
||||
from_addr = msg.get("From", "")
|
||||
to_addrs = msg.get("To", "")
|
||||
cc_addrs = msg.get("Cc", "")
|
||||
refs = msg.get("References", "")
|
||||
in_reply_to = msg.get("In-Reply-To")
|
||||
msg.get("Date", "")
|
||||
|
||||
# Compute thread_id from References/In-Reply-To
|
||||
thread_id = _compute_thread_id(message_id, refs, in_reply_to)
|
||||
|
||||
# Get INBOX folder for this account
|
||||
folder = (
|
||||
await db.execute(
|
||||
select(MailFolder).where(
|
||||
and_(
|
||||
MailFolder.account_id == account.id,
|
||||
MailFolder.imap_name == "INBOX",
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if folder is None:
|
||||
continue
|
||||
|
||||
mail = Mail(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
folder_id=folder.id,
|
||||
message_id=message_id,
|
||||
thread_id=thread_id,
|
||||
in_reply_to=in_reply_to,
|
||||
references_header=refs,
|
||||
subject=subject,
|
||||
from_address=from_addr,
|
||||
to_addresses=to_addrs,
|
||||
cc_addresses=cc_addrs,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
body_html_sanitized=sanitize_html(body_html),
|
||||
has_attachments=len(attachments) > 0,
|
||||
size_bytes=len(raw_email),
|
||||
received_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(mail)
|
||||
synced_count += 1
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Update folder counts
|
||||
folder = (
|
||||
# 2) Load existing folders from DB for this account
|
||||
existing_db_folders = (
|
||||
await db.execute(
|
||||
select(MailFolder).where(
|
||||
and_(
|
||||
MailFolder.account_id == account.id,
|
||||
MailFolder.imap_name == "INBOX",
|
||||
)
|
||||
and_(MailFolder.account_id == account.id, MailFolder.tenant_id == tenant_id)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if folder:
|
||||
total = (
|
||||
await db.execute(
|
||||
select(text("COUNT(*)")).where(
|
||||
and_(Mail.folder_id == folder.id, Mail.tenant_id == tenant_id)
|
||||
).scalars().all()
|
||||
existing_by_imap: dict[str, MailFolder] = {
|
||||
f.imap_name: f for f in existing_db_folders
|
||||
}
|
||||
|
||||
# 3) Create/update folders in DB
|
||||
db_folders = _build_folder_hierarchy(
|
||||
imap_folders, account.id, tenant_id, existing_by_imap
|
||||
)
|
||||
for folder in db_folders:
|
||||
if folder.id is None:
|
||||
db.add(folder)
|
||||
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}
|
||||
|
||||
synced_count = 0
|
||||
|
||||
# 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}"')
|
||||
if select_resp.result != 'OK':
|
||||
continue
|
||||
|
||||
# Fetch UIDs and sort by date descending — get last 50
|
||||
search_resp = await client.uid_search('ALL')
|
||||
uids_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b''
|
||||
if isinstance(uids_raw, (bytes, bytearray)):
|
||||
uids = uids_raw.split()
|
||||
else:
|
||||
uids = []
|
||||
|
||||
# Limit to last MAX_EMAILS_PER_FOLDER UIDs
|
||||
# UIDs are monotonically increasing, so the last ones are the newest
|
||||
if len(uids) > MAX_EMAILS_PER_FOLDER:
|
||||
uids = uids[-MAX_EMAILS_PER_FOLDER:]
|
||||
|
||||
for uid in uids:
|
||||
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
fetch_resp = await client.uid('fetch', uid_str, '(RFC822)')
|
||||
raw_email = None
|
||||
for line in (fetch_resp.lines if hasattr(fetch_resp, 'lines') else fetch_resp):
|
||||
if isinstance(line, bytearray):
|
||||
raw_email = bytes(line)
|
||||
break
|
||||
if raw_email is None:
|
||||
continue
|
||||
if isinstance(raw_email, str):
|
||||
raw_email = raw_email.encode()
|
||||
|
||||
msg = message_from_bytes(raw_email)
|
||||
body_text = ""
|
||||
body_html = ""
|
||||
attachments = []
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
if ct == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
body_text = payload.decode("utf-8", errors="replace")
|
||||
elif ct == "text/html":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
body_html = payload.decode("utf-8", errors="replace")
|
||||
elif part.get_filename():
|
||||
attachments.append(
|
||||
{
|
||||
"filename": part.get_filename(),
|
||||
"mime_type": ct,
|
||||
"size": len(part.get_payload(decode=True) or b""),
|
||||
}
|
||||
)
|
||||
else:
|
||||
ct = msg.get_content_type()
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
decoded = payload.decode("utf-8", errors="replace")
|
||||
if ct == "text/html":
|
||||
body_html = decoded
|
||||
else:
|
||||
body_text = decoded
|
||||
|
||||
message_id = msg.get("Message-ID", make_msgid())
|
||||
subject = msg.get("Subject", "")
|
||||
from_addr = msg.get("From", "")
|
||||
to_addrs = msg.get("To", "")
|
||||
cc_addrs = msg.get("Cc", "")
|
||||
refs = msg.get("References", "")
|
||||
in_reply_to = msg.get("In-Reply-To")
|
||||
date_str = msg.get("Date", "")
|
||||
|
||||
# Parse date for received_at
|
||||
received_at = datetime.now(UTC)
|
||||
if date_str:
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
parsed = parsedate_to_datetime(date_str)
|
||||
if parsed:
|
||||
received_at = parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Compute thread_id from References/In-Reply-To
|
||||
thread_id = _compute_thread_id(message_id, refs, in_reply_to)
|
||||
|
||||
# Check if email already exists (by message_id + folder)
|
||||
existing_mail = (
|
||||
await db.execute(
|
||||
select(Mail).where(
|
||||
and_(
|
||||
Mail.account_id == account.id,
|
||||
Mail.message_id == message_id,
|
||||
Mail.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing_mail:
|
||||
continue
|
||||
|
||||
mail = Mail(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
folder_id=folder.id,
|
||||
message_id=message_id,
|
||||
thread_id=thread_id,
|
||||
in_reply_to=in_reply_to,
|
||||
references_header=refs,
|
||||
subject=subject,
|
||||
from_address=from_addr,
|
||||
to_addresses=to_addrs,
|
||||
cc_addresses=cc_addrs,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
body_html_sanitized=sanitize_html(body_html),
|
||||
has_attachments=len(attachments) > 0,
|
||||
size_bytes=len(raw_email),
|
||||
received_at=received_at,
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
unread = (
|
||||
await db.execute(
|
||||
select(text("COUNT(*)")).where(
|
||||
and_(
|
||||
Mail.folder_id == folder.id,
|
||||
Mail.tenant_id == tenant_id,
|
||||
not Mail.is_seen,
|
||||
db.add(mail)
|
||||
synced_count += 1
|
||||
|
||||
# Update folder counts
|
||||
total = (
|
||||
await db.execute(
|
||||
select(text("COUNT(*)")).where(
|
||||
and_(Mail.folder_id == folder.id, Mail.tenant_id == tenant_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
folder.total_count = total
|
||||
folder.unread_count = unread
|
||||
await db.flush()
|
||||
).scalar()
|
||||
unread = (
|
||||
await db.execute(
|
||||
select(text("COUNT(*)")).where(
|
||||
and_(
|
||||
Mail.folder_id == folder.id,
|
||||
Mail.tenant_id == tenant_id,
|
||||
not Mail.is_seen,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
folder.total_count = total or 0
|
||||
folder.unread_count = unread or 0
|
||||
|
||||
await db.flush()
|
||||
|
||||
except Exception:
|
||||
# Skip folders that can't be selected (e.g. no select permission)
|
||||
continue
|
||||
|
||||
await db.flush()
|
||||
await client.logout()
|
||||
return {"synced": synced_count}
|
||||
except Exception as e:
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface MailFolder {
|
||||
id: string;
|
||||
account_id: string;
|
||||
name: string;
|
||||
imap_name?: string;
|
||||
parent_id: string | null;
|
||||
unread_count: number;
|
||||
total_count: number;
|
||||
|
||||
@@ -11,6 +11,36 @@ import type { MailAccount, MailFolder } from '@/api/mail';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
/**
|
||||
* Map IMAP folder names to German display names.
|
||||
*/
|
||||
const FOLDER_NAME_MAP: Record<string, string> = {
|
||||
INBOX: 'Posteingang',
|
||||
Sent: 'Gesendet',
|
||||
Drafts: 'Entwürfe',
|
||||
Spam: 'Spam',
|
||||
Junk: 'Spam',
|
||||
Trash: 'Papierkorb',
|
||||
'Sent Items': 'Gesendet',
|
||||
'Sent Mail': 'Gesendet',
|
||||
'Draft': 'Entwürfe',
|
||||
'Deleted': 'Papierkorb',
|
||||
'Deleted Items': 'Papierkorb',
|
||||
'Junk Email': 'Spam',
|
||||
'Junk E-mail': 'Spam',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the display name for a folder — uses the German name from the map
|
||||
* if the imap_name matches a known folder, otherwise uses the stored name.
|
||||
*/
|
||||
function getFolderDisplayName(folder: MailFolder): string {
|
||||
if (folder.imap_name && FOLDER_NAME_MAP[folder.imap_name]) {
|
||||
return FOLDER_NAME_MAP[folder.imap_name];
|
||||
}
|
||||
return folder.name;
|
||||
}
|
||||
|
||||
export interface MailFolderTreeProps {
|
||||
accounts: MailAccount[];
|
||||
folders: MailFolder[];
|
||||
@@ -90,7 +120,7 @@ function FolderNode({
|
||||
const hasChildren = folder.children && folder.children.length > 0;
|
||||
|
||||
return (
|
||||
<div role="treeitem" aria-selected={isSelected} aria-label={folder.name}>
|
||||
<div role="treeitem" aria-selected={isSelected} aria-label={getFolderDisplayName(folder)}>
|
||||
<div className="flex items-center" style={{ paddingLeft: `${depth * 12}px` }}>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
@@ -119,7 +149,7 @@ function FolderNode({
|
||||
data-testid={`folder-${folder.id}`}
|
||||
>
|
||||
<FolderIcon />
|
||||
<span className="flex-1 truncate text-left">{folder.name}</span>
|
||||
<span className="flex-1 truncate text-left">{getFolderDisplayName(folder)}</span>
|
||||
{folder.unread_count > 0 && (
|
||||
<Badge variant="primary" className="text-xs">{folder.unread_count}</Badge>
|
||||
)}
|
||||
|
||||
@@ -84,7 +84,8 @@ export function ResizablePanel({
|
||||
{children}
|
||||
{resizable && (
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary-300 active:bg-primary-400 transition-colors z-10"
|
||||
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"
|
||||
style={{ marginRight: '-3px' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
data-testid="resize-handle"
|
||||
role="separator"
|
||||
|
||||
@@ -380,5 +380,159 @@
|
||||
"paymentTermsDays": "Zahlungsziel (Tage)",
|
||||
"saved": "Systemeinstellungen gespeichert.",
|
||||
"saveFailed": "Speichern fehlgeschlagen."
|
||||
},
|
||||
"mail": {
|
||||
"title": "E-Mail",
|
||||
"settings": "E-Mail-Einstellungen",
|
||||
"accounts": "Mail-Accounts",
|
||||
"addAccount": "Account hinzufügen",
|
||||
"deleteAccount": "Löschen",
|
||||
"confirmDeleteAccount": "Möchten Sie diesen Mail-Account wirklich löschen? Alle zugehörigen E-Mails und Ordner werden entfernt.",
|
||||
"accountCreated": "Account erfolgreich erstellt.",
|
||||
"accountDeleted": "Account erfolgreich gelöscht.",
|
||||
"noAccounts": "Keine Mail-Accounts konfiguriert.",
|
||||
"noAccountsDesc": "Fügen Sie einen Mail-Account hinzu, um E-Mails zu empfangen und zu senden.",
|
||||
"configureAccount": "Account konfigurieren",
|
||||
"personal": "Persönlich",
|
||||
"shared": "Geteilt",
|
||||
"email": "E-Mail-Adresse",
|
||||
"displayName": "Anzeigename",
|
||||
"password": "Passwort",
|
||||
"imapHost": "IMAP-Server",
|
||||
"imapPort": "IMAP-Port",
|
||||
"smtpHost": "SMTP-Server",
|
||||
"smtpPort": "SMTP-Port",
|
||||
"serverInfo": "Server-Info",
|
||||
"testConnection": "Verbindung testen",
|
||||
"connectionSuccess": "Verbindung erfolgreich.",
|
||||
"syncNow": "Jetzt synchronisieren",
|
||||
"syncSuccess": "{{count}} E-Mails synchronisiert.",
|
||||
"selectAccount": "Account auswählen",
|
||||
"selectAccountFirst": "Bitte wählen Sie zuerst einen Account aus.",
|
||||
"folders": "Ordner",
|
||||
"noFolders": "Keine Ordner vorhanden",
|
||||
"noFoldersDesc": "Es sind noch keine Ordner konfiguriert.",
|
||||
"noMails": "Keine E-Mails",
|
||||
"noMailsDesc": "In diesem Ordner sind keine E-Mails vorhanden.",
|
||||
"noSubject": "Kein Betreff",
|
||||
"selectMail": "E-Mail auswählen",
|
||||
"selectMailToRead": "Keine E-Mail ausgewählt",
|
||||
"selectMailToReadDesc": "Wählen Sie eine E-Mail aus der Liste, um sie zu lesen.",
|
||||
"from": "Von",
|
||||
"to": "An",
|
||||
"cc": "CC",
|
||||
"bcc": "BCC",
|
||||
"date": "Datum",
|
||||
"subject": "Betreff",
|
||||
"subjectPlaceholder": "Betrereff eingeben...",
|
||||
"body": "Text",
|
||||
"attachments": "Anhänge",
|
||||
"download": "Herunterladen",
|
||||
"compose": "Verfassen",
|
||||
"send": "Senden",
|
||||
"sent": "E-Mail gesendet.",
|
||||
"reply": "Antworten",
|
||||
"forward": "Weiterleiten",
|
||||
"forwarding": "Weiterleitung",
|
||||
"flag": "Markieren",
|
||||
"unflag": "Markierung entfernen",
|
||||
"flagged": "Markiert",
|
||||
"toggleFlag": "Flagge umschalten",
|
||||
"unread": "Ungelesen",
|
||||
"createEvent": "Termin erstellen",
|
||||
"eventCreated": "Termin aus E-Mail erstellt.",
|
||||
"searchPlaceholder": "E-Mails durchsuchen...",
|
||||
"showCcBcc": "CC/BCC anzeigen",
|
||||
"htmlUnsafe": "HTML-Inhalt wurde aus Sicherheitsgründen bereinigt.",
|
||||
"bold": "Fett",
|
||||
"italic": "Kursiv",
|
||||
"insertLink": "Link einfügen",
|
||||
"linkUrl": "URL",
|
||||
"insertTemplate": "Vorlage einfügen",
|
||||
"template": "Vorlage",
|
||||
"selectTemplate": "Vorlage auswählen",
|
||||
"noTemplates": "Keine Vorlagen vorhanden",
|
||||
"noTemplatesDesc": "Es sind noch keine E-Mail-Vorlagen erstellt.",
|
||||
"signature": "Signatur",
|
||||
"signatures": "Signaturen",
|
||||
"signatureName": "Name der Signatur",
|
||||
"signatureBody": "Signaturtext",
|
||||
"newSignature": "Neue Signatur",
|
||||
"noSignature": "Keine Signatur",
|
||||
"noSignatures": "Keine Signaturen vorhanden",
|
||||
"noSignaturesDesc": "Erstellen Sie eine Signatur für Ihre E-Mails.",
|
||||
"defaultSignature": "Standard-Signatur",
|
||||
"signatureCreated": "Signatur erstellt.",
|
||||
"signatureUpdated": "Signatur aktualisiert.",
|
||||
"signatureDeleted": "Signatur gelöscht.",
|
||||
"confirmDeleteSignature": "Möchten Sie diese Signatur wirklich löschen?",
|
||||
"deleteSignature": "Signatur löschen",
|
||||
"rules": "Regeln",
|
||||
"newRule": "Neue Regel",
|
||||
"ruleName": "Regelname",
|
||||
"rulePriority": "Priorität",
|
||||
"ruleConditions": "Bedingungen",
|
||||
"ruleActions": "Aktionen",
|
||||
"addCondition": "Bedingung hinzufügen",
|
||||
"addAction": "Aktion hinzufügen",
|
||||
"ruleCondFromContains": "Von enthält",
|
||||
"ruleCondToContains": "An enthält",
|
||||
"ruleCondSubjectContains": "Betreff enthält",
|
||||
"ruleCondBodyContains": "Text enthält",
|
||||
"ruleCondHasAttachment": "Hat Anhang",
|
||||
"ruleCondValue": "Wert",
|
||||
"ruleActionMoveToFolder": "In Ordner verschieben",
|
||||
"ruleActionMarkAsRead": "Als gelesen markieren",
|
||||
"ruleActionMarkAsFlagged": "Als markiert markieren",
|
||||
"ruleActionForwardTo": "Weiterleiten an",
|
||||
"ruleActionDelete": "Löschen",
|
||||
"ruleActionValue": "Wert",
|
||||
"ruleCreated": "Regel erstellt.",
|
||||
"ruleDeleted": "Regel gelöscht.",
|
||||
"confirmDeleteRule": "Möchten Sie diese Regel wirklich löschen?",
|
||||
"deleteRule": "Regel löschen",
|
||||
"noRules": "Keine Regeln vorhanden",
|
||||
"noRulesDesc": "Erstellen Sie Regeln für die automatische E-Mail-Verarbeitung.",
|
||||
"labels": "Labels",
|
||||
"newLabel": "Neues Label",
|
||||
"labelName": "Label-Name",
|
||||
"labelColor": "Farbe",
|
||||
"labelCreated": "Label erstellt.",
|
||||
"labelDeleted": "Label gelöscht.",
|
||||
"confirmDeleteLabel": "Möchten Sie dieses Label wirklich löschen?",
|
||||
"deleteLabel": "Label löschen",
|
||||
"noLabels": "Keine Labels vorhanden",
|
||||
"noLabelsDesc": "Erstellen Sie Labels zur Organisation Ihrer E-Mails.",
|
||||
"vacationResponder": "Abwesenheitsnotiz",
|
||||
"enableVacation": "Abwesenheitsnotiz aktivieren",
|
||||
"vacationSubject": "Betreff",
|
||||
"vacationSubjectPlaceholder": "Abwesenheitsbetreff eingeben...",
|
||||
"vacationBody": "Nachricht",
|
||||
"vacationBodyPlaceholder": "Ihre Abwesenheitsnachricht...",
|
||||
"vacationStart": "Startdatum",
|
||||
"vacationEnd": "Enddatum",
|
||||
"vacationSaved": "Abwesenheitsnotiz gespeichert.",
|
||||
"pgpKeys": "PGP-Schlüssel",
|
||||
"pgpImportKey": "PGP-Schlüssel importieren",
|
||||
"importKey": "Schlüssel importieren",
|
||||
"privateKey": "Privater Schlüssel",
|
||||
"privateKeyLabel": "Privater PGP-Schlüssel",
|
||||
"publicKey": "Öffentlicher Schlüssel",
|
||||
"passphrase": "Passphrase",
|
||||
"pgpKeyImported": "PGP-Schlüssel erfolgreich importiert.",
|
||||
"contactPgpKeys": "PGP-Schlüssel der Kontakte",
|
||||
"contactPgpStored": "PGP-Schlüssel für Kontakt gespeichert.",
|
||||
"storeContactKey": "Öffentlichen Schlüssel speichern",
|
||||
"contactId": "Kontakt-ID",
|
||||
"noPgpKeys": "Keine PGP-Schlüssel vorhanden",
|
||||
"noPgpKeysDesc": "Importieren Sie einen PGP-Schlüssel für die Verschlüsselung.",
|
||||
"noContactKeys": "Keine Kontakt-Schlüssel vorhanden",
|
||||
"noContactKeysDesc": "Speichern Sie öffentliche Schlüssel Ihrer Kontakte.",
|
||||
"tabAccounts": "Accounts",
|
||||
"tabSignatures": "Signaturen",
|
||||
"tabRules": "Regeln",
|
||||
"tabLabels": "Labels",
|
||||
"tabVacation": "Abwesenheit",
|
||||
"tabPgp": "PGP-Verschlüsselung"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,5 +380,159 @@
|
||||
"paymentTermsDays": "Payment terms (days)",
|
||||
"saved": "System settings saved.",
|
||||
"saveFailed": "Failed to save settings."
|
||||
},
|
||||
"mail": {
|
||||
"title": "Email",
|
||||
"settings": "Email Settings",
|
||||
"accounts": "Mail Accounts",
|
||||
"addAccount": "Add Account",
|
||||
"deleteAccount": "Delete",
|
||||
"confirmDeleteAccount": "Are you sure you want to delete this mail account? All associated emails and folders will be removed.",
|
||||
"accountCreated": "Account created successfully.",
|
||||
"accountDeleted": "Account deleted successfully.",
|
||||
"noAccounts": "No mail accounts configured.",
|
||||
"noAccountsDesc": "Add a mail account to send and receive emails.",
|
||||
"configureAccount": "Configure Account",
|
||||
"personal": "Personal",
|
||||
"shared": "Shared",
|
||||
"email": "Email Address",
|
||||
"displayName": "Display Name",
|
||||
"password": "Password",
|
||||
"imapHost": "IMAP Server",
|
||||
"imapPort": "IMAP Port",
|
||||
"smtpHost": "SMTP Server",
|
||||
"smtpPort": "SMTP Port",
|
||||
"serverInfo": "Server Info",
|
||||
"testConnection": "Test Connection",
|
||||
"connectionSuccess": "Connection successful.",
|
||||
"syncNow": "Sync Now",
|
||||
"syncSuccess": "{{count}} emails synced.",
|
||||
"selectAccount": "Select Account",
|
||||
"selectAccountFirst": "Please select an account first.",
|
||||
"folders": "Folders",
|
||||
"noFolders": "No folders available",
|
||||
"noFoldersDesc": "No folders have been configured yet.",
|
||||
"noMails": "No emails",
|
||||
"noMailsDesc": "There are no emails in this folder.",
|
||||
"noSubject": "No Subject",
|
||||
"selectMail": "Select Email",
|
||||
"selectMailToRead": "No email selected",
|
||||
"selectMailToReadDesc": "Select an email from the list to read it.",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"cc": "CC",
|
||||
"bcc": "BCC",
|
||||
"date": "Date",
|
||||
"subject": "Subject",
|
||||
"subjectPlaceholder": "Enter subject...",
|
||||
"body": "Body",
|
||||
"attachments": "Attachments",
|
||||
"download": "Download",
|
||||
"compose": "Compose",
|
||||
"send": "Send",
|
||||
"sent": "Email sent.",
|
||||
"reply": "Reply",
|
||||
"forward": "Forward",
|
||||
"forwarding": "Forwarding",
|
||||
"flag": "Flag",
|
||||
"unflag": "Unflag",
|
||||
"flagged": "Flagged",
|
||||
"toggleFlag": "Toggle Flag",
|
||||
"unread": "Unread",
|
||||
"createEvent": "Create Event",
|
||||
"eventCreated": "Event created from email.",
|
||||
"searchPlaceholder": "Search emails...",
|
||||
"showCcBcc": "Show CC/BCC",
|
||||
"htmlUnsafe": "HTML content was sanitized for security.",
|
||||
"bold": "Bold",
|
||||
"italic": "Italic",
|
||||
"insertLink": "Insert Link",
|
||||
"linkUrl": "URL",
|
||||
"insertTemplate": "Insert Template",
|
||||
"template": "Template",
|
||||
"selectTemplate": "Select Template",
|
||||
"noTemplates": "No templates available",
|
||||
"noTemplatesDesc": "No email templates have been created yet.",
|
||||
"signature": "Signature",
|
||||
"signatures": "Signatures",
|
||||
"signatureName": "Signature Name",
|
||||
"signatureBody": "Signature Body",
|
||||
"newSignature": "New Signature",
|
||||
"noSignature": "No Signature",
|
||||
"noSignatures": "No signatures available",
|
||||
"noSignaturesDesc": "Create a signature for your emails.",
|
||||
"defaultSignature": "Default Signature",
|
||||
"signatureCreated": "Signature created.",
|
||||
"signatureUpdated": "Signature updated.",
|
||||
"signatureDeleted": "Signature deleted.",
|
||||
"confirmDeleteSignature": "Are you sure you want to delete this signature?",
|
||||
"deleteSignature": "Delete Signature",
|
||||
"rules": "Rules",
|
||||
"newRule": "New Rule",
|
||||
"ruleName": "Rule Name",
|
||||
"rulePriority": "Priority",
|
||||
"ruleConditions": "Conditions",
|
||||
"ruleActions": "Actions",
|
||||
"addCondition": "Add Condition",
|
||||
"addAction": "Add Action",
|
||||
"ruleCondFromContains": "From contains",
|
||||
"ruleCondToContains": "To contains",
|
||||
"ruleCondSubjectContains": "Subject contains",
|
||||
"ruleCondBodyContains": "Body contains",
|
||||
"ruleCondHasAttachment": "Has attachment",
|
||||
"ruleCondValue": "Value",
|
||||
"ruleActionMoveToFolder": "Move to folder",
|
||||
"ruleActionMarkAsRead": "Mark as read",
|
||||
"ruleActionMarkAsFlagged": "Mark as flagged",
|
||||
"ruleActionForwardTo": "Forward to",
|
||||
"ruleActionDelete": "Delete",
|
||||
"ruleActionValue": "Value",
|
||||
"ruleCreated": "Rule created.",
|
||||
"ruleDeleted": "Rule deleted.",
|
||||
"confirmDeleteRule": "Are you sure you want to delete this rule?",
|
||||
"deleteRule": "Delete Rule",
|
||||
"noRules": "No rules available",
|
||||
"noRulesDesc": "Create rules for automatic email processing.",
|
||||
"labels": "Labels",
|
||||
"newLabel": "New Label",
|
||||
"labelName": "Label Name",
|
||||
"labelColor": "Color",
|
||||
"labelCreated": "Label created.",
|
||||
"labelDeleted": "Label deleted.",
|
||||
"confirmDeleteLabel": "Are you sure you want to delete this label?",
|
||||
"deleteLabel": "Delete Label",
|
||||
"noLabels": "No labels available",
|
||||
"noLabelsDesc": "Create labels to organize your emails.",
|
||||
"vacationResponder": "Vacation Responder",
|
||||
"enableVacation": "Enable vacation responder",
|
||||
"vacationSubject": "Subject",
|
||||
"vacationSubjectPlaceholder": "Enter vacation subject...",
|
||||
"vacationBody": "Message",
|
||||
"vacationBodyPlaceholder": "Your vacation message...",
|
||||
"vacationStart": "Start Date",
|
||||
"vacationEnd": "End Date",
|
||||
"vacationSaved": "Vacation responder saved.",
|
||||
"pgpKeys": "PGP Keys",
|
||||
"pgpImportKey": "Import PGP Key",
|
||||
"importKey": "Import Key",
|
||||
"privateKey": "Private Key",
|
||||
"privateKeyLabel": "Private PGP Key",
|
||||
"publicKey": "Public Key",
|
||||
"passphrase": "Passphrase",
|
||||
"pgpKeyImported": "PGP key imported successfully.",
|
||||
"contactPgpKeys": "Contact PGP Keys",
|
||||
"contactPgpStored": "PGP key stored for contact.",
|
||||
"storeContactKey": "Store Public Key",
|
||||
"contactId": "Contact ID",
|
||||
"noPgpKeys": "No PGP keys available",
|
||||
"noPgpKeysDesc": "Import a PGP key for encryption.",
|
||||
"noContactKeys": "No contact keys available",
|
||||
"noContactKeysDesc": "Store public keys for your contacts.",
|
||||
"tabAccounts": "Accounts",
|
||||
"tabSignatures": "Signatures",
|
||||
"tabRules": "Rules",
|
||||
"tabLabels": "Labels",
|
||||
"tabVacation": "Vacation",
|
||||
"tabPgp": "PGP Encryption"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,21 +399,6 @@ export function MailPage() {
|
||||
),
|
||||
onClick: () => { /* sync trigger */ },
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
plugin: 'mail',
|
||||
label: 'Einstellungen',
|
||||
group: 'tools',
|
||||
icon: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => {
|
||||
window.location.href = '/settings/mail';
|
||||
},
|
||||
},
|
||||
]);
|
||||
return () => unregisterPlugin('mail');
|
||||
}, [selectedMailId, selectedMailFlagged, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]);
|
||||
@@ -461,7 +446,6 @@ export function MailPage() {
|
||||
data-testid="mail-folder-pane"
|
||||
>
|
||||
<div className="p-3">
|
||||
<h3 className="text-xs font-semibold text-secondary-500 uppercase mb-2">{t('mail.folders')}</h3>
|
||||
<MailFolderTree
|
||||
accounts={accounts}
|
||||
folders={folders}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PgpSettings } from '@/components/mail/PgpSettings';
|
||||
import {
|
||||
fetchAccounts,
|
||||
createAccount,
|
||||
deleteAccount,
|
||||
testConnection,
|
||||
triggerSync,
|
||||
type MailAccount,
|
||||
@@ -113,6 +114,17 @@ export function MailSettingsPage() {
|
||||
}
|
||||
}, [toast, t]);
|
||||
|
||||
const handleDeleteAccount = useCallback(async (accountId: string) => {
|
||||
if (!window.confirm(t('mail.confirmDeleteAccount'))) return;
|
||||
try {
|
||||
await deleteAccount(accountId);
|
||||
setAccounts((prev) => prev.filter((a) => a.id !== accountId));
|
||||
toast.success(t('mail.accountDeleted'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [toast, t]);
|
||||
|
||||
const activeTab = searchParams.get('tab') || 'accounts';
|
||||
|
||||
const tabs = [
|
||||
@@ -201,15 +213,18 @@ export function MailSettingsPage() {
|
||||
{accounts.map((acc) => (
|
||||
<Card key={acc.id}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-secondary-900">{acc.display_name}</p>
|
||||
<p className="text-sm text-secondary-500">{acc.email}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-secondary-900">{acc.display_name || acc.email}</p>
|
||||
<p className="text-sm text-secondary-500 truncate">{acc.email}</p>
|
||||
<p className="text-xs text-secondary-400 mt-1">
|
||||
{t('mail.serverInfo')}: {acc.imap_host}:{acc.imap_port} / {acc.smtp_host}:{acc.smtp_port}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-400">
|
||||
{acc.is_shared ? t('mail.shared') : t('mail.personal')} ·
|
||||
{acc.is_shared ? t('mail.shared') : t('mail.personal')} ·
|
||||
{acc.is_active ? t('common.active') : t('common.inactive')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -228,6 +243,14 @@ export function MailSettingsPage() {
|
||||
>
|
||||
{t('mail.syncNow')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteAccount(acc.id)}
|
||||
data-testid={`delete-account-${acc.id}`}
|
||||
>
|
||||
{t('mail.deleteAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user