fix: mail UI German labels, multi-folder sync, account management, resizable panel fix

This commit is contained in:
Agent Zero
2026-07-15 14:36:45 +02:00
parent 0409a08002
commit e0975f4044
8 changed files with 690 additions and 151 deletions
+225 -33
View File
@@ -217,6 +217,134 @@ 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
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( async def imap_sync_account(
db: AsyncSession, db: AsyncSession,
account_id: uuid.UUID, account_id: uuid.UUID,
@@ -224,8 +352,10 @@ async def imap_sync_account(
) -> dict: ) -> dict:
"""Sync mail folders and messages from IMAP server. """Sync mail folders and messages from IMAP server.
This function is designed to be called as an ARQ background job. Syncs ALL folders from the IMAP server (not just INBOX).
In tests it is mocked — real implementation connects via aioimaplib. 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 = ( account = (
await db.execute( await db.execute(
@@ -239,23 +369,76 @@ async def imap_sync_account(
password = await get_account_password(account) password = await get_account_password(account)
# Import aioimaplib here so tests can mock it
try: try:
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port) client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
await client.wait_hello_from_server() await client.wait_hello_from_server()
await client.login(account.username, password) await client.login(account.username, password)
await client.select("INBOX")
# Fetch all message UIDs # 1) LIST all folders from IMAP server
response = await client.uid_search("ALL") list_response = await client.list('""', '"*"')
uids = response[1][0].split() if response[1] and response[1][0] else [] imap_folders = _parse_imap_list_response(list_response)
# If LIST returned nothing, fall back to standard folders
if not imap_folders:
imap_folders = [
('', 'INBOX'),
('', 'Sent'),
('', 'Drafts'),
('', 'Spam'),
('', 'Trash'),
]
# 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.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 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: for uid in uids:
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid) uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
fetch_resp = await client.uid('fetch', uid_str, '(RFC822)') fetch_resp = await client.uid('fetch', uid_str, '(RFC822)')
raw_email = None 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): for line in (fetch_resp.lines if hasattr(fetch_resp, 'lines') else fetch_resp):
if isinstance(line, bytearray): if isinstance(line, bytearray):
raw_email = bytes(line) raw_email = bytes(line)
@@ -274,9 +457,13 @@ async def imap_sync_account(
for part in msg.walk(): for part in msg.walk():
ct = part.get_content_type() ct = part.get_content_type()
if ct == "text/plain": if ct == "text/plain":
body_text = part.get_payload(decode=True).decode("utf-8", errors="replace") payload = part.get_payload(decode=True)
if payload:
body_text = payload.decode("utf-8", errors="replace")
elif ct == "text/html": elif ct == "text/html":
body_html = part.get_payload(decode=True).decode("utf-8", errors="replace") payload = part.get_payload(decode=True)
if payload:
body_html = payload.decode("utf-8", errors="replace")
elif part.get_filename(): elif part.get_filename():
attachments.append( attachments.append(
{ {
@@ -302,23 +489,35 @@ async def imap_sync_account(
cc_addrs = msg.get("Cc", "") cc_addrs = msg.get("Cc", "")
refs = msg.get("References", "") refs = msg.get("References", "")
in_reply_to = msg.get("In-Reply-To") in_reply_to = msg.get("In-Reply-To")
msg.get("Date", "") 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 # Compute thread_id from References/In-Reply-To
thread_id = _compute_thread_id(message_id, refs, in_reply_to) thread_id = _compute_thread_id(message_id, refs, in_reply_to)
# Get INBOX folder for this account # Check if email already exists (by message_id + folder)
folder = ( existing_mail = (
await db.execute( await db.execute(
select(MailFolder).where( select(Mail).where(
and_( and_(
MailFolder.account_id == account.id, Mail.account_id == account.id,
MailFolder.imap_name == "INBOX", Mail.message_id == message_id,
Mail.tenant_id == tenant_id,
) )
) )
) )
).scalar_one_or_none() ).scalar_one_or_none()
if folder is None: if existing_mail:
continue continue
mail = Mail( mail = Mail(
@@ -338,25 +537,12 @@ async def imap_sync_account(
body_html_sanitized=sanitize_html(body_html), body_html_sanitized=sanitize_html(body_html),
has_attachments=len(attachments) > 0, has_attachments=len(attachments) > 0,
size_bytes=len(raw_email), size_bytes=len(raw_email),
received_at=datetime.now(UTC), received_at=received_at,
) )
db.add(mail) db.add(mail)
synced_count += 1 synced_count += 1
await db.flush()
# Update folder counts # Update folder counts
folder = (
await db.execute(
select(MailFolder).where(
and_(
MailFolder.account_id == account.id,
MailFolder.imap_name == "INBOX",
)
)
)
).scalar_one_or_none()
if folder:
total = ( total = (
await db.execute( await db.execute(
select(text("COUNT(*)")).where( select(text("COUNT(*)")).where(
@@ -375,10 +561,16 @@ async def imap_sync_account(
) )
) )
).scalar() ).scalar()
folder.total_count = total folder.total_count = total or 0
folder.unread_count = unread folder.unread_count = unread or 0
await db.flush() await db.flush()
except Exception:
# Skip folders that can't be selected (e.g. no select permission)
continue
await db.flush()
await client.logout() await client.logout()
return {"synced": synced_count} return {"synced": synced_count}
except Exception as e: except Exception as e:
+1
View File
@@ -27,6 +27,7 @@ export interface MailFolder {
id: string; id: string;
account_id: string; account_id: string;
name: string; name: string;
imap_name?: string;
parent_id: string | null; parent_id: string | null;
unread_count: number; unread_count: number;
total_count: number; total_count: number;
@@ -11,6 +11,36 @@ import type { MailAccount, MailFolder } from '@/api/mail';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { EmptyState } from '@/components/ui/EmptyState'; 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 { export interface MailFolderTreeProps {
accounts: MailAccount[]; accounts: MailAccount[];
folders: MailFolder[]; folders: MailFolder[];
@@ -90,7 +120,7 @@ function FolderNode({
const hasChildren = folder.children && folder.children.length > 0; const hasChildren = folder.children && folder.children.length > 0;
return ( 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` }}> <div className="flex items-center" style={{ paddingLeft: `${depth * 12}px` }}>
{hasChildren ? ( {hasChildren ? (
<button <button
@@ -119,7 +149,7 @@ function FolderNode({
data-testid={`folder-${folder.id}`} data-testid={`folder-${folder.id}`}
> >
<FolderIcon /> <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 && ( {folder.unread_count > 0 && (
<Badge variant="primary" className="text-xs">{folder.unread_count}</Badge> <Badge variant="primary" className="text-xs">{folder.unread_count}</Badge>
)} )}
@@ -84,7 +84,8 @@ export function ResizablePanel({
{children} {children}
{resizable && ( {resizable && (
<div <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} onMouseDown={handleMouseDown}
data-testid="resize-handle" data-testid="resize-handle"
role="separator" role="separator"
+154
View File
@@ -380,5 +380,159 @@
"paymentTermsDays": "Zahlungsziel (Tage)", "paymentTermsDays": "Zahlungsziel (Tage)",
"saved": "Systemeinstellungen gespeichert.", "saved": "Systemeinstellungen gespeichert.",
"saveFailed": "Speichern fehlgeschlagen." "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"
} }
} }
+154
View File
@@ -380,5 +380,159 @@
"paymentTermsDays": "Payment terms (days)", "paymentTermsDays": "Payment terms (days)",
"saved": "System settings saved.", "saved": "System settings saved.",
"saveFailed": "Failed to save settings." "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"
} }
} }
-16
View File
@@ -399,21 +399,6 @@ export function MailPage() {
), ),
onClick: () => { /* sync trigger */ }, 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'); return () => unregisterPlugin('mail');
}, [selectedMailId, selectedMailFlagged, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]); }, [selectedMailId, selectedMailFlagged, handleSearch, handleCompose, handleReply, handleForward, handleToggleFlag, handleCreateEvent]);
@@ -461,7 +446,6 @@ export function MailPage() {
data-testid="mail-folder-pane" data-testid="mail-folder-pane"
> >
<div className="p-3"> <div className="p-3">
<h3 className="text-xs font-semibold text-secondary-500 uppercase mb-2">{t('mail.folders')}</h3>
<MailFolderTree <MailFolderTree
accounts={accounts} accounts={accounts}
folders={folders} folders={folders}
+27 -4
View File
@@ -18,6 +18,7 @@ import { PgpSettings } from '@/components/mail/PgpSettings';
import { import {
fetchAccounts, fetchAccounts,
createAccount, createAccount,
deleteAccount,
testConnection, testConnection,
triggerSync, triggerSync,
type MailAccount, type MailAccount,
@@ -113,6 +114,17 @@ export function MailSettingsPage() {
} }
}, [toast, t]); }, [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 activeTab = searchParams.get('tab') || 'accounts';
const tabs = [ const tabs = [
@@ -201,15 +213,18 @@ export function MailSettingsPage() {
{accounts.map((acc) => ( {accounts.map((acc) => (
<Card key={acc.id}> <Card key={acc.id}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="min-w-0 flex-1">
<p className="font-medium text-secondary-900">{acc.display_name}</p> <p className="font-medium text-secondary-900">{acc.display_name || acc.email}</p>
<p className="text-sm text-secondary-500">{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"> <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')} {acc.is_active ? t('common.active') : t('common.inactive')}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 flex-shrink-0">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -228,6 +243,14 @@ export function MailSettingsPage() {
> >
{t('mail.syncNow')} {t('mail.syncNow')}
</Button> </Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteAccount(acc.id)}
data-testid={`delete-account-${acc.id}`}
>
{t('mail.deleteAccount')}
</Button>
</div> </div>
</div> </div>
</Card> </Card>