From ea6c9e71db1a53776835fa129c5f431b870989b8 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 26 Aug 2026 14:33:30 +0200 Subject: [PATCH] =?UTF-8?q?refactor(i-g):=20BUG-018=20Pilot=20Split=20Schr?= =?UTF-8?q?itt=205=20=E2=80=94=20mail/services.py=20komplett=20zur=20Fassa?= =?UTF-8?q?de=20reduziert=20(-95%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services.py: 3087 -> ~170 Zeilen reine Re-Export-Fassade. Alle Implementierung jetzt in 12 Sub-Modulen: accounts/crypto/drafts_sync/imap_ops/imap_sync/pgp/rules_vacation/sanitize/serializers/smtp_send/text_utils/attachments. Fixes waehrend Extraktion: (1) get_account_password async statt sync (brach send/reply/forward), (2) aiosmtplib als Modulattribut fuer Test-Mocks, (3) conftest Mock-Pfad auf imap_sync statt services, (4) test_mail.py SMTP-Mock-Pfade auf smtp_send umgestellt, (5) Fassade fehlende Symbole ergaenzt: MAX_ATTACHMENT_SIZE/_sanitize_filename/imap_create_folder/imap_delete_folder/mail_to_response. Beweis: mail+sig_label_routes 51/51 passed in 106.88s; alle 13 Sub-Module Import-OK; ruff clean; Symbol-Aufloesung MISSING: NONE. --- app/plugins/builtins/mail/accounts.py | 115 ++ app/plugins/builtins/mail/drafts_sync.py | 546 +++++++ app/plugins/builtins/mail/imap_ops.py | 452 ++++++ app/plugins/builtins/mail/rules_vacation.py | 194 +++ app/plugins/builtins/mail/services.py | 1440 ++----------------- tests/conftest.py | 2 +- tests/test_mail.py | 6 +- 7 files changed, 1434 insertions(+), 1321 deletions(-) create mode 100644 app/plugins/builtins/mail/accounts.py create mode 100644 app/plugins/builtins/mail/drafts_sync.py create mode 100644 app/plugins/builtins/mail/imap_ops.py create mode 100644 app/plugins/builtins/mail/rules_vacation.py diff --git a/app/plugins/builtins/mail/accounts.py b/app/plugins/builtins/mail/accounts.py new file mode 100644 index 0000000..afb1aa0 --- /dev/null +++ b/app/plugins/builtins/mail/accounts.py @@ -0,0 +1,115 @@ +"""Mail Account Service for the Mail plugin. + +Extracted from services.py as part of the God-object split (BUG-018 pilot). +Re-exported by ``app.plugins.builtins.mail.services``. +""" + +from __future__ import annotations + +import logging +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.plugins.builtins.mail.crypto import encrypt_password, generate_salt +from app.plugins.builtins.mail.models import ( + MailAccount, + MailFolder, +) + +logger = logging.getLogger(__name__) + + +async def create_mail_account( + db: AsyncSession, *, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict +) -> MailAccount: + """Create a new mail account with encrypted password.""" + salt = generate_salt() + account = MailAccount( + tenant_id=tenant_id, + user_id=user_id, + owner_id=user_id, + email_address=data["email_address"], + display_name=data.get("display_name", ""), + imap_host=data["imap_host"], + imap_port=data.get("imap_port", 993), + imap_ssl=data.get("imap_ssl", True), + smtp_host=data["smtp_host"], + smtp_port=data.get("smtp_port", 587), + smtp_tls=data.get("smtp_tls", True), + username=data.get("username") or data["email_address"], + encrypted_password=encrypt_password(data["password"], salt), + password_salt=salt, + is_shared=data.get("is_shared", False), + is_active=True, + sent_folder_imap_name=data.get("sent_folder_imap_name"), + drafts_folder_imap_name=data.get("drafts_folder_imap_name"), + spam_folder_imap_name=data.get("spam_folder_imap_name"), + trash_folder_imap_name=data.get("trash_folder_imap_name"), + ) + db.add(account) + await db.flush() + + # 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 [ + ("Gesendet", "INBOX.Sent"), + ("Entwürfe", "INBOX.Drafts"), + ("Papierkorb", "INBOX.Trash"), + ("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) + await db.flush() + return account + + +async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict) -> MailAccount: + """Update a mail account, encrypting password if changed.""" + 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", + "sent_folder_imap_name": "sent_folder_imap_name", + "drafts_folder_imap_name": "drafts_folder_imap_name", + "spam_folder_imap_name": "spam_folder_imap_name", + "trash_folder_imap_name": "trash_folder_imap_name", + } + 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: + new_salt = generate_salt() + account.password_salt = new_salt + account.encrypted_password = encrypt_password(data["password"], new_salt) + await db.flush() + await db.refresh(account) + return account + + diff --git a/app/plugins/builtins/mail/drafts_sync.py b/app/plugins/builtins/mail/drafts_sync.py new file mode 100644 index 0000000..8b92f95 --- /dev/null +++ b/app/plugins/builtins/mail/drafts_sync.py @@ -0,0 +1,546 @@ +"""Draft save/update and auto-sync for the Mail plugin. + +Extracted from services.py as part of the God-object split (BUG-018 pilot). +Re-exported by ``app.plugins.builtins.mail.services``. +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import UTC, datetime +from email.message import EmailMessage +from email.utils import formataddr, formatdate, make_msgid + +import aioimaplib +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.notifications import create_notification +from app.plugins.builtins.mail.imap_ops import imap_delete_mail, imap_move_mail +from app.plugins.builtins.mail.imap_sync import ( + get_account_password, + imap_sync_account, +) +from app.plugins.builtins.mail.models import ( + Mail, + MailAccount, + MailFolder, +) + +logger = logging.getLogger(__name__) + + +async def save_draft( + db: AsyncSession, + account_id: uuid.UUID, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + data: dict, +) -> Mail: + """Save a new draft mail to DB and IMAP Drafts folder.""" + import logging + + logger = logging.getLogger(__name__) + + # 1. Find the Drafts folder (imap_name contains 'Drafts') + account = ( + await db.execute( + select(MailAccount).where( + and_(MailAccount.id == account_id, MailAccount.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not account: + raise ValueError("Account not found") + + folders = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.account_id == account_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalars().all() + + drafts_folder = None + for f in folders: + if 'draft' in f.imap_name.lower(): + drafts_folder = f + break + + if not drafts_folder: + raise ValueError("No Drafts folder found for this account") + + # 2. Create Mail record + to_str = ', '.join(data.get('to', [])) + cc_str = ', '.join(data.get('cc', [])) + bcc_str = ', '.join(data.get('bcc', [])) + subject = data.get('subject', '') + body_text = data.get('body_text', '') + body_html = data.get('body_html', '') + + msg_id = make_msgid() + now = datetime.now(UTC) + + mail = Mail( + tenant_id=tenant_id, + account_id=account_id, + folder_id=drafts_folder.id, + message_id=msg_id, + thread_id=msg_id, + subject=subject, + from_address=account.email_address, + to_addresses=to_str, + cc_addresses=cc_str, + bcc_addresses=bcc_str, + body_text=body_text, + body_html=body_html, + body_html_sanitized=body_html, + is_seen=True, + is_flagged=False, + is_draft=True, + is_answered=False, + is_forwarded=False, + has_attachments=False, + size_bytes=len(body_text.encode('utf-8')), + received_at=now, + sent_at=None, + ) + db.add(mail) + await db.flush() + + # ── Notification: draft saved ── + try: + await create_notification( + db, tenant_id, user_id, + "mail_draft", + "Entwurf gespeichert", + subject or "Ohne Betreff", + ) + await db.flush() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + # 3. Build RFC822 message and APPEND to IMAP Drafts folder + password = await get_account_password(account) + client = None + 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) + + # Build RFC822 message + email_msg = EmailMessage() + email_msg['From'] = formataddr((account.display_name, account.email_address)) + if to_str: + email_msg['To'] = to_str + if cc_str: + email_msg['Cc'] = cc_str + email_msg['Subject'] = subject + email_msg['Date'] = formatdate(localtime=True) + email_msg['Message-ID'] = msg_id + email_msg.set_content(body_text if body_text else '') + if body_html: + email_msg.add_alternative(body_html, subtype='html') + + rfc822_bytes = email_msg.as_bytes() + + # APPEND to Drafts folder + append_resp = await client.append( + drafts_folder.imap_name, + r'(\\Draft)', + str(int(now.timestamp())), + rfc822_bytes, + ) + if append_resp.result != 'OK': + logger.warning("save_draft: IMAP APPEND failed for drafts folder %s", drafts_folder.imap_name) + else: + logger.info("save_draft: appended draft %s to IMAP Drafts", mail.id) + + except Exception as exc: + logger.warning("save_draft: IMAP append failed (non-critical): %s", exc) + finally: + if client is not None: + try: + await client.logout() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + return mail + + +async def update_draft( + db: AsyncSession, + mail_id: uuid.UUID, + tenant_id: uuid.UUID, + data: dict, +) -> Mail: + """Update an existing draft.""" + import logging + + logger = logging.getLogger(__name__) + + mail = ( + await db.execute( + select(Mail).where( + and_(Mail.id == mail_id, Mail.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not mail: + raise ValueError("Mail not found") + if not mail.is_draft: + raise ValueError("Mail is not a draft") + + # 2. Update fields + to_str = ', '.join(data.get('to', [])) + cc_str = ', '.join(data.get('cc', [])) + bcc_str = ', '.join(data.get('bcc', [])) + mail.to_addresses = to_str + mail.cc_addresses = cc_str + mail.bcc_addresses = bcc_str + mail.subject = data.get('subject', '') + mail.body_text = data.get('body_text', '') + mail.body_html = data.get('body_html', '') + mail.body_html_sanitized = data.get('body_html', '') + mail.size_bytes = len(mail.body_text.encode('utf-8')) + await db.flush() + + # 3. Delete old IMAP copy and APPEND new one + folder = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not folder: + return mail + + account = ( + await db.execute( + select(MailAccount).where( + and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not account: + return mail + + password = await get_account_password(account) + client = None + 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) + + # Delete old copy from IMAP + select_resp = await client.select(folder.imap_name) + if select_resp.result == 'OK' and mail.message_id: + search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"') + 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 = [] + if uids: + uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0]) + await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') + await client.expunge() + + # APPEND new copy + email_msg = EmailMessage() + email_msg['From'] = formataddr((account.display_name, account.email_address)) + if to_str: + email_msg['To'] = to_str + if cc_str: + email_msg['Cc'] = cc_str + email_msg['Subject'] = mail.subject + email_msg['Date'] = formatdate(localtime=True) + email_msg['Message-ID'] = mail.message_id + email_msg.set_content(mail.body_text if mail.body_text else '') + if mail.body_html: + email_msg.add_alternative(mail.body_html, subtype='html') + + rfc822_bytes = email_msg.as_bytes() + now = datetime.now(UTC) + append_resp = await client.append( + folder.imap_name, + r'(\\Draft)', + str(int(now.timestamp())), + rfc822_bytes, + ) + if append_resp.result != 'OK': + logger.warning("update_draft: IMAP APPEND failed for drafts folder %s", folder.imap_name) + else: + logger.info("update_draft: appended updated draft %s to IMAP Drafts", mail.id) + + except Exception as exc: + logger.warning("update_draft: IMAP sync failed (non-critical): %s", exc) + finally: + if client is not None: + try: + await client.logout() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + return mail + + +# ─── IMAP Folder Create / Delete ─── + + +async def imap_create_folder( + db: AsyncSession, account_id: uuid.UUID, folder_name: str, tenant_id: uuid.UUID +) -> None: + """Create folder on IMAP server. + + Connects to IMAP, creates folder with CREATE command. + Non-critical: errors are logged, DB operation still succeeds. + """ + import logging + + logger = logging.getLogger(__name__) + + account = ( + await db.execute( + select(MailAccount).where( + and_(MailAccount.id == account_id, MailAccount.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not account: + logger.warning("imap_create_folder: account %s not found", account_id) + return + + password = await get_account_password(account) + client = None + + 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) + + resp = await client.create(folder_name) + if resp.result != "OK": + logger.warning( + "imap_create_folder: CREATE failed for %s: %s", folder_name, resp + ) + else: + logger.info("imap_create_folder: created folder %s on IMAP", folder_name) + try: + await create_notification( + db, account.tenant_id, account.user_id, + "mail_folder", + "Ordner erstellt", + folder_name, + ) + await db.flush() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + except Exception as exc: + logger.warning("imap_create_folder: failed (non-critical): %s", exc) + finally: + if client is not None: + try: + await client.logout() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + +async def imap_delete_folder( + db: AsyncSession, folder_id: uuid.UUID, tenant_id: uuid.UUID +) -> None: + """Delete folder from IMAP server. + + Connects to IMAP, deletes folder with DELETE command. + Non-critical: errors are logged, DB operation still succeeds. + """ + import logging + + logger = logging.getLogger(__name__) + + folder = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.id == folder_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not folder: + logger.warning("imap_delete_folder: folder %s not found", folder_id) + return + + account = ( + await db.execute( + select(MailAccount).where( + and_( + MailAccount.id == folder.account_id, + MailAccount.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + if not account: + logger.warning("imap_delete_folder: account not found for folder %s", folder_id) + return + + password = await get_account_password(account) + client = None + + 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) + + resp = await client.delete(folder.imap_name) + if resp.result != "OK": + logger.warning( + "imap_delete_folder: DELETE failed for %s: %s", folder.imap_name, resp + ) + else: + logger.info("imap_delete_folder: deleted folder %s on IMAP", folder.imap_name) + try: + await create_notification( + db, account.tenant_id, account.user_id, + "mail_folder", + "Ordner gelöscht", + folder.imap_name, + ) + await db.flush() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + except Exception as exc: + logger.warning("imap_delete_folder: failed (non-critical): %s", exc) + finally: + if client is not None: + try: + await client.logout() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + +# ─── Auto-Sync ─── + + +async def auto_sync_all_accounts() -> None: + """Auto-sync all active mail accounts. + + Called periodically by the background scheduler. + Iterates all active mail accounts and syncs each one. + """ + import logging + + from app.core.db import get_session_factory + + logger = logging.getLogger(__name__) + + factory = get_session_factory() + async with factory() as db: + accounts = ( + await db.execute( + select(MailAccount).where(MailAccount.is_active.is_(True)) + ) + ).scalars().all() + + if not accounts: + return + + logger.info("auto_sync_all_accounts: syncing %d active account(s)", len(accounts)) + + for account in accounts: + try: + result = await imap_sync_account(db, account.id, account.tenant_id) + logger.info( + "auto_sync_all_accounts: synced account %s (%s): %s", + account.id, + account.username, + result, + ) + except Exception as exc: + logger.warning( + "auto_sync_all_accounts: failed for account %s: %s", + account.id, + exc, + ) + try: + await create_notification( + db, account.tenant_id, account.user_id, + "mail_sync_error", + "Synchronisierung fehlgeschlagen", + f"Account {account.email_address}: {exc}", + ) + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + # commit per-account so partial progress is saved + try: + await db.commit() + except Exception: + await db.rollback() + + +async def process_sync_queue(db: AsyncSession) -> None: + """Process pending IMAP operations from the sync queue. + + Called at the start of each auto-sync loop iteration. + Retries failed delete/move operations. + """ + import logging + + logger = logging.getLogger(__name__) + + from app.plugins.builtins.mail.models import MailSyncQueue + + pending = ( + await db.execute( + select(MailSyncQueue).where( + and_( + MailSyncQueue.status == "pending", + MailSyncQueue.attempts < MailSyncQueue.max_attempts, + ) + ) + ) + ).scalars().all() + + if not pending: + return + + logger.info("process_sync_queue: processing %d pending operation(s)", len(pending)) + + for entry in pending: + try: + if entry.operation == "delete": + is_permanent = entry.payload.get("permanent", False) if entry.payload else False + await imap_delete_mail(db, entry.mail_id, entry.tenant_id, permanent=is_permanent) + elif entry.operation == "move": + target_folder_id = uuid.UUID(entry.payload.get("target_folder_id", "")) + await imap_move_mail(db, entry.mail_id, target_folder_id, entry.tenant_id) + else: + logger.warning("process_sync_queue: unknown operation %s", entry.operation) + entry.status = "failed" + entry.last_error = f"Unknown operation: {entry.operation}" + continue + + entry.status = "completed" + entry.updated_at = datetime.now(UTC) + logger.info("process_sync_queue: completed %s for mail %s", entry.operation, entry.mail_id) + + except Exception as exc: + entry.attempts += 1 + entry.last_error = str(exc) + entry.updated_at = datetime.now(UTC) + if entry.attempts >= entry.max_attempts: + entry.status = "failed" + logger.warning( + "process_sync_queue: giving up on %s for mail %s after %d attempts: %s", + entry.operation, entry.mail_id, entry.attempts, exc, + ) + else: + logger.info( + "process_sync_queue: retry %d/%d for %s mail %s: %s", + entry.attempts, entry.max_attempts, entry.operation, entry.mail_id, exc, + ) + + await db.flush() diff --git a/app/plugins/builtins/mail/imap_ops.py b/app/plugins/builtins/mail/imap_ops.py new file mode 100644 index 0000000..66ef418 --- /dev/null +++ b/app/plugins/builtins/mail/imap_ops.py @@ -0,0 +1,452 @@ +"""IMAP operations (flags, delete, move, folders) for the Mail plugin. + +Extracted from services.py as part of the God-object split (BUG-018 pilot). +Re-exported by ``app.plugins.builtins.mail.services``. +""" + +from __future__ import annotations + +import logging +import uuid + +import aioimaplib +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.plugins.builtins.mail.imap_sync import ( + _parse_imap_list_response, + get_account_password, +) +from app.plugins.builtins.mail.models import ( + Mail, + MailAccount, + MailFolder, +) + +logger = logging.getLogger(__name__) + + +async def imap_sync_mail_flags( + db: AsyncSession, + mail_id: uuid.UUID, + tenant_id: uuid.UUID, +) -> None: + """Sync is_seen/is_flagged flags from DB to IMAP server. + + Connects to the IMAP server, selects the mail's folder, + and uses UID STORE to set/remove \\Seen and \\Flagged flags. + Non-critical: logs warnings on failure but does not raise. + """ + import logging + + logger = logging.getLogger(__name__) + + # Load the mail with its folder and account + mail = ( + await db.execute( + select(Mail).where( + and_(Mail.id == mail_id, Mail.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not mail: + logger.warning("imap_sync_mail_flags: mail %s not found", mail_id) + return + + folder = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not folder: + logger.warning("imap_sync_mail_flags: folder %s not found for mail %s", mail.folder_id, mail_id) + return + + account = ( + await db.execute( + select(MailAccount).where( + and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not account: + logger.warning("imap_sync_mail_flags: account not found for mail %s", mail_id) + return + + if not mail.message_id: + logger.warning("imap_sync_mail_flags: mail %s has no message_id, cannot sync", mail_id) + return + + password = await get_account_password(account) + client = None + + 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) + + # Select the folder + select_resp = await client.select(folder.imap_name) + if select_resp.result != 'OK': + logger.warning("imap_sync_mail_flags: cannot select folder %s", folder.imap_name) + return + + # Find the UID by searching for the Message-ID header + search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"') + 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 = [] + + if not uids: + logger.warning("imap_sync_mail_flags: no UID found for Message-ID %s", mail.message_id) + return + + uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0]) + + # Sync \Seen flag + if mail.is_seen: + await client.uid('store', uid_str, '+FLAGS (\\Seen)') + else: + await client.uid('store', uid_str, '-FLAGS (\\Seen)') + + # Sync \Flagged flag + if mail.is_flagged: + await client.uid('store', uid_str, '+FLAGS (\\Flagged)') + else: + await client.uid('store', uid_str, '-FLAGS (\\Flagged)') + + logger.info("imap_sync_mail_flags: synced flags for mail %s (UID %s)", mail_id, uid_str) + + except Exception as exc: + logger.warning("imap_sync_mail_flags: failed for mail %s: %s", mail_id, exc) + finally: + if client is not None: + try: + await client.logout() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + +# ─── IMAP Delete ─── + + +async def _find_trash_folder_name( + db: AsyncSession, account: MailAccount, tenant_id: uuid.UUID +) -> str | None: + """Find the IMAP Trash folder name for an account. + + Checks account.trash_folder_imap_name, then DB folders with 'trash' in imap_name, + then queries the IMAP server LIST for common Trash folder names. + """ + # 1) Explicit mapping on account + if account.trash_folder_imap_name: + return account.trash_folder_imap_name + + # 2) DB folder with 'trash' in imap_name + db_folders = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.account_id == account.id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalars().all() + for f in db_folders: + if 'trash' in f.imap_name.lower(): + return f.imap_name + + # 3) Query IMAP server for common Trash folder names + password = await get_account_password(account) + client = None + 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) + list_resp = await client.list('""', '"*"') + imap_folders, _ = _parse_imap_list_response(list_resp) + trash_candidates = ['Trash', 'INBOX.Trash', 'INBOX.Trash', 'Deleted', 'Deleted Items', 'Papierkorb'] + for _, name in imap_folders: + if name in trash_candidates or 'trash' in name.lower(): + return name + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + finally: + if client: + try: + await client.logout() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + return None + + +async def imap_delete_mail( + db: AsyncSession, mail_id: uuid.UUID, tenant_id: uuid.UUID, *, permanent: bool = False +) -> None: + """Delete mail from IMAP server. + + If permanent=False (default): MOVE mail to IMAP Trash folder (like real mail clients). + If permanent=True: STORE \\Deleted + EXPUNGE (permanent delete from server). + Uses UID MOVE if supported, otherwise COPY + STORE \\Deleted + EXPUNGE. + Raises exceptions on failure so caller can queue for retry. + """ + import logging + + logger = logging.getLogger(__name__) + + mail = ( + await db.execute( + select(Mail).where( + and_(Mail.id == mail_id, Mail.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not mail: + logger.warning("imap_delete_mail: mail %s not found", mail_id) + return + + folder = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not folder: + logger.warning("imap_delete_mail: folder %s not found for mail %s", mail.folder_id, mail_id) + return + + account = ( + await db.execute( + select(MailAccount).where( + and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not account: + logger.warning("imap_delete_mail: account not found for mail %s", mail_id) + return + + if not mail.imap_uid and not mail.message_id: + logger.warning("imap_delete_mail: mail %s has no imap_uid or message_id, cannot sync", mail_id) + return + + # Lifecycle hook: mail.before_delete + from app.core.hooks import do_action + await do_action("mail.before_delete", mail_id=str(mail.id), tenant_id=str(tenant_id), permanent=permanent, db=db) + + # Find Trash folder on IMAP server (only needed for non-permanent delete) + trash_folder_name = None + if not permanent: + trash_folder_name = await _find_trash_folder_name(db, account, tenant_id) + if not trash_folder_name: + logger.warning("imap_delete_mail: no Trash folder found for account %s, will permanent delete", account.id) + + password = await get_account_password(account) + client = None + + 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) + + select_resp = await client.select(folder.imap_name) + if select_resp.result != 'OK': + raise RuntimeError(f"cannot select folder {folder.imap_name}") + + # Use stored imap_uid directly; fall back to Message-ID search + if mail.imap_uid: + uid_str = mail.imap_uid + else: + search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"') + 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 = [] + if not uids: + raise RuntimeError(f"no UID found for Message-ID {mail.message_id}") + uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0]) + + if permanent or not trash_folder_name: + # Permanent delete: STORE \Deleted + EXPUNGE + await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') + await client.expunge() + logger.info("imap_delete_mail: permanent deleted mail %s (UID %s)", mail_id, uid_str) + return + + # Non-permanent: MOVE to Trash folder (like Thunderbird, Outlook, etc.) + # Don't move if already in Trash + if folder.imap_name.lower() == trash_folder_name.lower(): + # Already in Trash — permanent delete + await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') + await client.expunge() + logger.info("imap_delete_mail: permanent deleted mail %s (already in Trash, UID %s)", mail_id, uid_str) + return + + # Try UID MOVE first + try: + move_resp = await client.uid('move', uid_str, trash_folder_name) + if move_resp.result == 'OK': + logger.info("imap_delete_mail: moved mail %s (UID %s) to Trash %s", mail_id, uid_str, trash_folder_name) + return + except Exception as move_exc: + logger.info("imap_delete_mail: UID MOVE not supported, falling back: %s", move_exc) + + # Fallback: COPY to Trash + STORE \Deleted + EXPUNGE + copy_resp = await client.uid('copy', uid_str, trash_folder_name) + if copy_resp.result != 'OK': + raise RuntimeError(f"COPY to Trash failed for mail {mail_id}") + await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') + await client.expunge() + logger.info("imap_delete_mail: moved mail %s (UID %s) to Trash via COPY+DELETE", mail_id, uid_str) + + # Lifecycle hook: mail.after_delete + from app.core.hooks import do_action + await do_action("mail.after_delete", mail_id=str(mail.id), tenant_id=str(tenant_id), permanent=permanent, db=db) + + except Exception as exc: + logger.warning("imap_delete_mail: failed for mail %s: %s", mail_id, exc) + raise + finally: + if client is not None: + try: + await client.logout() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + +# ─── IMAP Move ─── + + +async def imap_move_mail( + db: AsyncSession, + mail_id: uuid.UUID, + target_folder_id: uuid.UUID, + tenant_id: uuid.UUID, +) -> None: + """Move mail to another folder on IMAP server. + + Uses stored imap_uid directly; falls back to Message-ID search if missing. + Uses UID MOVE if supported, otherwise COPY + STORE \\Deleted + EXPUNGE. + Raises exceptions on failure so caller can queue for retry. + """ + import logging + + logger = logging.getLogger(__name__) + + mail = ( + await db.execute( + select(Mail).where( + and_(Mail.id == mail_id, Mail.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not mail: + logger.warning("imap_move_mail: mail %s not found", mail_id) + return + + source_folder = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not source_folder: + logger.warning("imap_move_mail: source folder not found for mail %s", mail_id) + return + + target_folder = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.id == target_folder_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not target_folder: + logger.warning("imap_move_mail: target folder %s not found", target_folder_id) + return + + account = ( + await db.execute( + select(MailAccount).where( + and_(MailAccount.id == source_folder.account_id, MailAccount.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if not account: + logger.warning("imap_move_mail: account not found for mail %s", mail_id) + return + + if not mail.imap_uid and not mail.message_id: + logger.warning("imap_move_mail: mail %s has no imap_uid or message_id, cannot sync", mail_id) + return + + # Lifecycle hook: mail.before_move + from app.core.hooks import do_action + await do_action("mail.before_move", mail_id=str(mail.id), tenant_id=str(tenant_id), source_folder_id=str(source_folder.id), target_folder_id=str(target_folder.id), db=db) + + password = await get_account_password(account) + client = None + + 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) + + select_resp = await client.select(source_folder.imap_name) + if select_resp.result != 'OK': + raise RuntimeError(f"cannot select folder {source_folder.imap_name}") + + # Use stored imap_uid directly; fall back to Message-ID search + if mail.imap_uid: + uid_str = mail.imap_uid + else: + search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"') + 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 = [] + if not uids: + raise RuntimeError(f"no UID found for Message-ID {mail.message_id}") + uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0]) + + # Try UID MOVE first; fall back to COPY + STORE \\Deleted + EXPUNGE + try: + move_resp = await client.uid('move', uid_str, target_folder.imap_name) + if move_resp.result == 'OK': + logger.info("imap_move_mail: moved mail %s (UID %s) via UID MOVE", mail_id, uid_str) + return + except Exception as move_exc: + logger.info("imap_move_mail: UID MOVE not supported, falling back: %s", move_exc) + + # Fallback: COPY + STORE \\Deleted + EXPUNGE + copy_resp = await client.uid('copy', uid_str, target_folder.imap_name) + if copy_resp.result != 'OK': + raise RuntimeError(f"COPY failed for mail {mail_id}") + + await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') + await client.expunge() + + logger.info("imap_move_mail: moved mail %s (UID %s) via COPY+DELETE", mail_id, uid_str) + + # Lifecycle hook: mail.after_move + from app.core.hooks import do_action + await do_action("mail.after_move", mail_id=str(mail.id), tenant_id=str(tenant_id), source_folder_id=str(source_folder.id), target_folder_id=str(target_folder.id), db=db) + + except Exception as exc: + logger.warning("imap_move_mail: failed for mail %s: %s", mail_id, exc) + raise + finally: + if client is not None: + try: + await client.logout() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + diff --git a/app/plugins/builtins/mail/rules_vacation.py b/app/plugins/builtins/mail/rules_vacation.py new file mode 100644 index 0000000..d03ce85 --- /dev/null +++ b/app/plugins/builtins/mail/rules_vacation.py @@ -0,0 +1,194 @@ +"""Rules, Templates & Vacation for the Mail plugin. + +Extracted from services.py as part of the God-object split (BUG-018 pilot). +Re-exported by ``app.plugins.builtins.mail.services``. +""" + +from __future__ import annotations + +import json +import logging +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.plugins.builtins.mail.models import ( + Mail, + MailFolder, + MailLabelAssignment, + MailRule, + VacationSentLog, +) + +logger = logging.getLogger(__name__) + + +def substitute_template_vars(template_body: str, variables: dict[str, str]) -> str: + """Replace {{placeholder}} variables in template body.""" + result = template_body + for key, value in variables.items(): + result = result.replace(f"{{{{{key}}}}}", value) + result = result.replace(f"{{{{{key.lower()}}}}}", value) + result = result.replace(f"{{{{{key.upper()}}}}}", value) + return result + + +# ─── Mail Rule Engine (F-MAIL-07) ─── + + +def matches_condition(mail: Mail, conditions: dict) -> bool: + """Check if a mail matches all rule conditions.""" + for field, expected in conditions.items(): + if field == "from_contains": + if expected.lower() not in mail.from_address.lower(): + return False + elif field == "subject_contains": + if expected.lower() not in mail.subject.lower(): + return False + elif field == "to_contains": + if expected.lower() not in mail.to_addresses.lower(): + return False + elif field == "body_contains": + body = (mail.body_text + mail.body_html).lower() + if expected.lower() not in body: + return False + elif field == "has_attachments": + if mail.has_attachments != bool(expected): + return False + elif field == "is_flagged": + if mail.is_flagged != bool(expected): + return False + return True + + +async def execute_rule_actions( + db: AsyncSession, mail: Mail, actions: dict, tenant_id: uuid.UUID +) -> dict: + """Execute rule actions on a matching mail.""" + results = {} + for action, value in actions.items(): + if action == "move_to_folder": + folder_id = uuid.UUID(value) if isinstance(value, str) else value + folder = ( + await db.execute( + select(MailFolder).where( + and_(MailFolder.id == folder_id, MailFolder.tenant_id == tenant_id) + ) + ) + ).scalar_one_or_none() + if folder: + mail.folder_id = folder.id + results["moved"] = str(folder.id) + elif action == "label": + label_id = uuid.UUID(value) if isinstance(value, str) else value + existing = ( + await db.execute( + select(MailLabelAssignment).where( + and_( + MailLabelAssignment.mail_id == mail.id, + MailLabelAssignment.label_id == label_id, + ) + ) + ) + ).scalar_one_or_none() + if not existing: + assignment = MailLabelAssignment( + tenant_id=tenant_id, + mail_id=mail.id, + label_id=label_id, + ) + db.add(assignment) + results["labeled"] = str(label_id) + elif action == "mark_seen": + mail.is_seen = bool(value) + results["seen"] = bool(value) + elif action == "mark_flagged": + mail.is_flagged = bool(value) + results["flagged"] = bool(value) + elif action == "forward_to": + results["forward_to"] = value + await db.flush() + return results + + +async def apply_rules_to_mail(db: AsyncSession, mail: Mail, tenant_id: uuid.UUID) -> list[dict]: + """Find and apply all matching rules to a mail, sorted by priority.""" + rules = ( + ( + await db.execute( + select(MailRule) + .where( + and_( + MailRule.tenant_id == tenant_id, + MailRule.is_active, + or_( + MailRule.account_id == mail.account_id, + MailRule.account_id.is_(None), + ), + ) + ) + .order_by(MailRule.priority) + ) + ) + .scalars() + .all() + ) + + applied = [] + for rule in rules: + conditions = json.loads(rule.conditions) if rule.conditions else {} + actions = json.loads(rule.actions) if rule.actions else {} + if matches_condition(mail, conditions): + result = await execute_rule_actions(db, mail, actions, tenant_id) + applied.append({"rule_id": str(rule.id), "rule_name": rule.name, "actions": result}) + return applied + + +# ─── Vacation Auto-Reply (F-MAIL-08) ─── + + +VACATION_DEDUP_HOURS = 24 + + +async def should_send_vacation_reply( + db: AsyncSession, + account_id: uuid.UUID, + sender_address: str, + tenant_id: uuid.UUID, +) -> bool: + """Check if vacation auto-reply should be sent (dedup within 24h).""" + cutoff = datetime.now(UTC) - timedelta(hours=VACATION_DEDUP_HOURS) + existing = ( + await db.execute( + select(VacationSentLog).where( + and_( + VacationSentLog.account_id == account_id, + VacationSentLog.sender_address == sender_address, + VacationSentLog.sent_at >= cutoff, + VacationSentLog.tenant_id == tenant_id, + ) + ) + ) + ).scalar_one_or_none() + return existing is None + + +async def log_vacation_sent( + db: AsyncSession, + account_id: uuid.UUID, + sender_address: str, + tenant_id: uuid.UUID, +) -> None: + """Log that a vacation auto-reply was sent to a sender.""" + log = VacationSentLog( + tenant_id=tenant_id, + account_id=account_id, + sender_address=sender_address, + sent_at=datetime.now(UTC), + ) + db.add(log) + await db.flush() + + diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index dcfe4e1..b6c57e8 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -1,34 +1,50 @@ -"""Service layer for the Mail plugin: encryption, IMAP sync, SMTP send, rules, vacation, PGP.""" +"""Service layer for the Mail plugin — pure re-export facade. -from __future__ import annotations +All implementation lives in focused sub-modules: + accounts.py — create/update mail account service + crypto.py — AES-256 password encryption (Fernet) + drafts_sync.py — draft save/update, auto-sync, sync queue processing + imap_ops.py — IMAP flag sync, delete, move, folder create/delete + imap_sync.py — folder sync, account sync, thread-id, quota parser + pgp.py — PGP key import, encrypt/decrypt messages + sanitize.py — nh3 HTML sanitization + serializers.py — response dict builders for all ORM models + smtp_send.py — send via SMTP, reply, forward + text_utils.py — extract email addresses, strip HTML +""" -import json import logging -import os -import re -import uuid -from datetime import UTC, datetime, timedelta -from email.message import EmailMessage -from email.utils import formataddr, formatdate, make_msgid -import aiofiles -import aioimaplib -import aiosmtplib # noqa: F401 — test_mail.py patches services.aiosmtplib.SMTP -from sqlalchemy import and_, or_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.core.notifications import create_notification - -# ── Re-exports from extracted sub-modules (BUG-018 pilot split) ── -# Backwards compatibility: all consumers still import from -# ``app.plugins.builtins.mail.services`` unchanged. -from app.plugins.builtins.mail.crypto import ( # noqa: E402,F401 +from app.plugins.builtins.mail.accounts import ( + create_mail_account, + update_mail_account, +) +from app.plugins.builtins.mail.attachments import ( # noqa: F401 — routes.py re-exports + MAX_ATTACHMENT_SIZE, + _attachment_storage_path, + _decode_mime_filename, + _sanitize_filename, + _save_attachment_to_storage, +) +from app.plugins.builtins.mail.crypto import ( decrypt_password, encrypt_password, generate_salt, ) -from app.plugins.builtins.mail.imap_sync import ( # noqa: E402,F401 +from app.plugins.builtins.mail.drafts_sync import ( + auto_sync_all_accounts, + imap_create_folder, # noqa: F401 — routes.py re-export + imap_delete_folder, # noqa: F401 — routes.py re-export + process_sync_queue, + save_draft, + update_draft, +) +from app.plugins.builtins.mail.imap_ops import ( + imap_delete_mail, + imap_move_mail, + imap_sync_mail_flags, +) +from app.plugins.builtins.mail.imap_sync import ( _compute_thread_id, _get_german_folder_name, _parse_imap_list_response, @@ -40,1325 +56,115 @@ from app.plugins.builtins.mail.imap_sync import ( # noqa: E402,F401 from app.plugins.builtins.mail.models import ( Mail, MailAccount, + MailAttachment, MailFolder, + MailLabel, MailLabelAssignment, MailRule, + MailSignature, + MailTemplate, VacationSentLog, ) -from app.plugins.builtins.mail.pgp import ( # noqa: E402,F401 +from app.plugins.builtins.mail.pgp import ( import_pgp_private_key, import_pgp_public_key, pgp_decrypt_message, pgp_encrypt_message, ) -from app.plugins.builtins.mail.sanitize import sanitize_html # noqa: E402,F401 -from app.plugins.builtins.mail.serializers import ( # noqa: E402,F401 +from app.plugins.builtins.mail.rules_vacation import ( + apply_rules_to_mail, + execute_rule_actions, + log_vacation_sent, + matches_condition, + should_send_vacation_reply, + substitute_template_vars, +) +from app.plugins.builtins.mail.sanitize import sanitize_html +from app.plugins.builtins.mail.serializers import ( account_to_response, attachment_to_response, folder_to_response, label_to_response, - mail_to_response, + mail_to_response, # noqa: F401 rule_to_response, signature_to_response, template_to_response, ) -from app.plugins.builtins.mail.smtp_send import ( # noqa: E402,F401 +from app.plugins.builtins.mail.smtp_send import ( forward_mail, reply_to_mail, send_mail_via_smtp, ) -from app.plugins.builtins.mail.text_utils import ( # noqa: E402,F401 +from app.plugins.builtins.mail.text_utils import ( _strip_html, extract_email_addresses, ) logger = logging.getLogger(__name__) -# ─── Attachment Storage Helpers ─── - -MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25 MB - - -def _decode_mime_filename(filename: str) -> str: - """Decode MIME-encoded filename, handling =?charset?Q?...?= and =?charset?B?...?= patterns.""" - if not filename: - return "attachment" - # If no MIME encoding pattern, return as-is - if "=?" not in filename: - return filename - try: - from email.header import decode_header, make_header - return str(make_header(decode_header(filename))) - except Exception: - # Fallback: manually decode Q-encoding if decode_header fails - # This handles cases where the email parser partially processed the filename - try: - import re - def decode_q(match): - charset, encoding, encoded = match.group(1), match.group(2).upper(), match.group(3) - if encoding == 'B': - import base64 - decoded = base64.b64decode(encoded).decode(charset or 'utf-8', errors='replace') - else: # Q encoding - decoded = encoded.replace('_', ' ') - decoded = re.sub(r'=([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), decoded) - decoded = decoded.encode('latin-1').decode(charset or 'utf-8', errors='replace') - return decoded - return re.sub(r'=\?([^?]+)\?([BbQq])\?([^?]*)\?=', decode_q, filename) - except Exception: - return filename - - -def _sanitize_filename(filename: str) -> str: - """Sanitize a filename to prevent path traversal attacks.""" - # Remove any path components — keep only the basename - filename = os.path.basename(filename or "attachment") - # Replace potentially dangerous characters - filename = re.sub(r"[^a-zA-Z0-9._-]", "_", filename) - # Ensure non-empty - if not filename: - filename = "attachment" - # Limit length - if len(filename) > 200: - name, ext = os.path.splitext(filename) - filename = name[:200 - len(ext)] + ext - return filename - - -def _attachment_storage_path(mail_id: uuid.UUID, filename: str) -> str: - """Build the on-disk storage path for a mail attachment.""" - safe_name = _sanitize_filename(filename) - return os.path.join( - settings.storage_path, - "mail_attachments", - str(mail_id), - safe_name, - ) - - -async def _save_attachment_to_storage( - mail_id: uuid.UUID, filename: str, content: bytes -) -> str: - """Save attachment content to disk and return the storage path.""" - storage_path = _attachment_storage_path(mail_id, filename) - os.makedirs(os.path.dirname(storage_path), exist_ok=True) - async with aiofiles.open(storage_path, "wb") as f: - await f.write(content) - return storage_path - - -# ─── IMAP Quota Parser ─── - - -# ─── Mail Account Service ─── - - -async def create_mail_account( - db: AsyncSession, *, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict -) -> MailAccount: - """Create a new mail account with encrypted password.""" - salt = generate_salt() - account = MailAccount( - tenant_id=tenant_id, - user_id=user_id, - owner_id=user_id, - email_address=data["email_address"], - display_name=data.get("display_name", ""), - imap_host=data["imap_host"], - imap_port=data.get("imap_port", 993), - imap_ssl=data.get("imap_ssl", True), - smtp_host=data["smtp_host"], - smtp_port=data.get("smtp_port", 587), - smtp_tls=data.get("smtp_tls", True), - username=data.get("username") or data["email_address"], - encrypted_password=encrypt_password(data["password"], salt), - password_salt=salt, - is_shared=data.get("is_shared", False), - is_active=True, - sent_folder_imap_name=data.get("sent_folder_imap_name"), - drafts_folder_imap_name=data.get("drafts_folder_imap_name"), - spam_folder_imap_name=data.get("spam_folder_imap_name"), - trash_folder_imap_name=data.get("trash_folder_imap_name"), - ) - db.add(account) - await db.flush() - - # 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 [ - ("Gesendet", "INBOX.Sent"), - ("Entwürfe", "INBOX.Drafts"), - ("Papierkorb", "INBOX.Trash"), - ("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) - await db.flush() - return account - - -async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict) -> MailAccount: - """Update a mail account, encrypting password if changed.""" - 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", - "sent_folder_imap_name": "sent_folder_imap_name", - "drafts_folder_imap_name": "drafts_folder_imap_name", - "spam_folder_imap_name": "spam_folder_imap_name", - "trash_folder_imap_name": "trash_folder_imap_name", - } - 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: - new_salt = generate_salt() - account.password_salt = new_salt - account.encrypted_password = encrypt_password(data["password"], new_salt) - await db.flush() - await db.refresh(account) - return account - - -# ─── Template Service (F-MAIL-06) ─── - - -def substitute_template_vars(template_body: str, variables: dict[str, str]) -> str: - """Replace {{placeholder}} variables in template body.""" - result = template_body - for key, value in variables.items(): - result = result.replace(f"{{{{{key}}}}}", value) - result = result.replace(f"{{{{{key.lower()}}}}}", value) - result = result.replace(f"{{{{{key.upper()}}}}}", value) - return result - - -# ─── Mail Rule Engine (F-MAIL-07) ─── - - -def matches_condition(mail: Mail, conditions: dict) -> bool: - """Check if a mail matches all rule conditions.""" - for field, expected in conditions.items(): - if field == "from_contains": - if expected.lower() not in mail.from_address.lower(): - return False - elif field == "subject_contains": - if expected.lower() not in mail.subject.lower(): - return False - elif field == "to_contains": - if expected.lower() not in mail.to_addresses.lower(): - return False - elif field == "body_contains": - body = (mail.body_text + mail.body_html).lower() - if expected.lower() not in body: - return False - elif field == "has_attachments": - if mail.has_attachments != bool(expected): - return False - elif field == "is_flagged": - if mail.is_flagged != bool(expected): - return False - return True - - -async def execute_rule_actions( - db: AsyncSession, mail: Mail, actions: dict, tenant_id: uuid.UUID -) -> dict: - """Execute rule actions on a matching mail.""" - results = {} - for action, value in actions.items(): - if action == "move_to_folder": - folder_id = uuid.UUID(value) if isinstance(value, str) else value - folder = ( - await db.execute( - select(MailFolder).where( - and_(MailFolder.id == folder_id, MailFolder.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if folder: - mail.folder_id = folder.id - results["moved"] = str(folder.id) - elif action == "label": - label_id = uuid.UUID(value) if isinstance(value, str) else value - existing = ( - await db.execute( - select(MailLabelAssignment).where( - and_( - MailLabelAssignment.mail_id == mail.id, - MailLabelAssignment.label_id == label_id, - ) - ) - ) - ).scalar_one_or_none() - if not existing: - assignment = MailLabelAssignment( - tenant_id=tenant_id, - mail_id=mail.id, - label_id=label_id, - ) - db.add(assignment) - results["labeled"] = str(label_id) - elif action == "mark_seen": - mail.is_seen = bool(value) - results["seen"] = bool(value) - elif action == "mark_flagged": - mail.is_flagged = bool(value) - results["flagged"] = bool(value) - elif action == "forward_to": - results["forward_to"] = value - await db.flush() - return results - - -async def apply_rules_to_mail(db: AsyncSession, mail: Mail, tenant_id: uuid.UUID) -> list[dict]: - """Find and apply all matching rules to a mail, sorted by priority.""" - rules = ( - ( - await db.execute( - select(MailRule) - .where( - and_( - MailRule.tenant_id == tenant_id, - MailRule.is_active, - or_( - MailRule.account_id == mail.account_id, - MailRule.account_id.is_(None), - ), - ) - ) - .order_by(MailRule.priority) - ) - ) - .scalars() - .all() - ) - - applied = [] - for rule in rules: - conditions = json.loads(rule.conditions) if rule.conditions else {} - actions = json.loads(rule.actions) if rule.actions else {} - if matches_condition(mail, conditions): - result = await execute_rule_actions(db, mail, actions, tenant_id) - applied.append({"rule_id": str(rule.id), "rule_name": rule.name, "actions": result}) - return applied - - -# ─── Vacation Auto-Reply (F-MAIL-08) ─── - - -VACATION_DEDUP_HOURS = 24 - - -async def should_send_vacation_reply( - db: AsyncSession, - account_id: uuid.UUID, - sender_address: str, - tenant_id: uuid.UUID, -) -> bool: - """Check if vacation auto-reply should be sent (dedup within 24h).""" - cutoff = datetime.now(UTC) - timedelta(hours=VACATION_DEDUP_HOURS) - existing = ( - await db.execute( - select(VacationSentLog).where( - and_( - VacationSentLog.account_id == account_id, - VacationSentLog.sender_address == sender_address, - VacationSentLog.sent_at >= cutoff, - VacationSentLog.tenant_id == tenant_id, - ) - ) - ) - ).scalar_one_or_none() - return existing is None - - -async def log_vacation_sent( - db: AsyncSession, - account_id: uuid.UUID, - sender_address: str, - tenant_id: uuid.UUID, -) -> None: - """Log that a vacation auto-reply was sent to a sender.""" - log = VacationSentLog( - tenant_id=tenant_id, - account_id=account_id, - sender_address=sender_address, - sent_at=datetime.now(UTC), - ) - db.add(log) - await db.flush() - - -# ─── Contact Linking (F-MAIL-10) ─── - - -# ─── IMAP Flag Sync ─── - - -async def imap_sync_mail_flags( - db: AsyncSession, - mail_id: uuid.UUID, - tenant_id: uuid.UUID, -) -> None: - """Sync is_seen/is_flagged flags from DB to IMAP server. - - Connects to the IMAP server, selects the mail's folder, - and uses UID STORE to set/remove \\Seen and \\Flagged flags. - Non-critical: logs warnings on failure but does not raise. - """ - import logging - - logger = logging.getLogger(__name__) - - # Load the mail with its folder and account - mail = ( - await db.execute( - select(Mail).where( - and_(Mail.id == mail_id, Mail.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not mail: - logger.warning("imap_sync_mail_flags: mail %s not found", mail_id) - return - - folder = ( - await db.execute( - select(MailFolder).where( - and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not folder: - logger.warning("imap_sync_mail_flags: folder %s not found for mail %s", mail.folder_id, mail_id) - return - - account = ( - await db.execute( - select(MailAccount).where( - and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not account: - logger.warning("imap_sync_mail_flags: account not found for mail %s", mail_id) - return - - if not mail.message_id: - logger.warning("imap_sync_mail_flags: mail %s has no message_id, cannot sync", mail_id) - return - - password = await get_account_password(account) - client = None - - 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) - - # Select the folder - select_resp = await client.select(folder.imap_name) - if select_resp.result != 'OK': - logger.warning("imap_sync_mail_flags: cannot select folder %s", folder.imap_name) - return - - # Find the UID by searching for the Message-ID header - search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"') - 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 = [] - - if not uids: - logger.warning("imap_sync_mail_flags: no UID found for Message-ID %s", mail.message_id) - return - - uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0]) - - # Sync \Seen flag - if mail.is_seen: - await client.uid('store', uid_str, '+FLAGS (\\Seen)') - else: - await client.uid('store', uid_str, '-FLAGS (\\Seen)') - - # Sync \Flagged flag - if mail.is_flagged: - await client.uid('store', uid_str, '+FLAGS (\\Flagged)') - else: - await client.uid('store', uid_str, '-FLAGS (\\Flagged)') - - logger.info("imap_sync_mail_flags: synced flags for mail %s (UID %s)", mail_id, uid_str) - - except Exception as exc: - logger.warning("imap_sync_mail_flags: failed for mail %s: %s", mail_id, exc) - finally: - if client is not None: - try: - await client.logout() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - -# ─── IMAP Delete ─── - - -async def _find_trash_folder_name( - db: AsyncSession, account: MailAccount, tenant_id: uuid.UUID -) -> str | None: - """Find the IMAP Trash folder name for an account. - - Checks account.trash_folder_imap_name, then DB folders with 'trash' in imap_name, - then queries the IMAP server LIST for common Trash folder names. - """ - # 1) Explicit mapping on account - if account.trash_folder_imap_name: - return account.trash_folder_imap_name - - # 2) DB folder with 'trash' in imap_name - db_folders = ( - await db.execute( - select(MailFolder).where( - and_(MailFolder.account_id == account.id, MailFolder.tenant_id == tenant_id) - ) - ) - ).scalars().all() - for f in db_folders: - if 'trash' in f.imap_name.lower(): - return f.imap_name - - # 3) Query IMAP server for common Trash folder names - password = await get_account_password(account) - client = None - 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) - list_resp = await client.list('""', '"*"') - imap_folders, _ = _parse_imap_list_response(list_resp) - trash_candidates = ['Trash', 'INBOX.Trash', 'INBOX.Trash', 'Deleted', 'Deleted Items', 'Papierkorb'] - for _, name in imap_folders: - if name in trash_candidates or 'trash' in name.lower(): - return name - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - finally: - if client: - try: - await client.logout() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - return None - - -async def imap_delete_mail( - db: AsyncSession, mail_id: uuid.UUID, tenant_id: uuid.UUID, *, permanent: bool = False -) -> None: - """Delete mail from IMAP server. - - If permanent=False (default): MOVE mail to IMAP Trash folder (like real mail clients). - If permanent=True: STORE \\Deleted + EXPUNGE (permanent delete from server). - Uses UID MOVE if supported, otherwise COPY + STORE \\Deleted + EXPUNGE. - Raises exceptions on failure so caller can queue for retry. - """ - import logging - - logger = logging.getLogger(__name__) - - mail = ( - await db.execute( - select(Mail).where( - and_(Mail.id == mail_id, Mail.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not mail: - logger.warning("imap_delete_mail: mail %s not found", mail_id) - return - - folder = ( - await db.execute( - select(MailFolder).where( - and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not folder: - logger.warning("imap_delete_mail: folder %s not found for mail %s", mail.folder_id, mail_id) - return - - account = ( - await db.execute( - select(MailAccount).where( - and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not account: - logger.warning("imap_delete_mail: account not found for mail %s", mail_id) - return - - if not mail.imap_uid and not mail.message_id: - logger.warning("imap_delete_mail: mail %s has no imap_uid or message_id, cannot sync", mail_id) - return - - # Lifecycle hook: mail.before_delete - from app.core.hooks import do_action - await do_action("mail.before_delete", mail_id=str(mail.id), tenant_id=str(tenant_id), permanent=permanent, db=db) - - # Find Trash folder on IMAP server (only needed for non-permanent delete) - trash_folder_name = None - if not permanent: - trash_folder_name = await _find_trash_folder_name(db, account, tenant_id) - if not trash_folder_name: - logger.warning("imap_delete_mail: no Trash folder found for account %s, will permanent delete", account.id) - - password = await get_account_password(account) - client = None - - 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) - - select_resp = await client.select(folder.imap_name) - if select_resp.result != 'OK': - raise RuntimeError(f"cannot select folder {folder.imap_name}") - - # Use stored imap_uid directly; fall back to Message-ID search - if mail.imap_uid: - uid_str = mail.imap_uid - else: - search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"') - 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 = [] - if not uids: - raise RuntimeError(f"no UID found for Message-ID {mail.message_id}") - uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0]) - - if permanent or not trash_folder_name: - # Permanent delete: STORE \Deleted + EXPUNGE - await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') - await client.expunge() - logger.info("imap_delete_mail: permanent deleted mail %s (UID %s)", mail_id, uid_str) - return - - # Non-permanent: MOVE to Trash folder (like Thunderbird, Outlook, etc.) - # Don't move if already in Trash - if folder.imap_name.lower() == trash_folder_name.lower(): - # Already in Trash — permanent delete - await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') - await client.expunge() - logger.info("imap_delete_mail: permanent deleted mail %s (already in Trash, UID %s)", mail_id, uid_str) - return - - # Try UID MOVE first - try: - move_resp = await client.uid('move', uid_str, trash_folder_name) - if move_resp.result == 'OK': - logger.info("imap_delete_mail: moved mail %s (UID %s) to Trash %s", mail_id, uid_str, trash_folder_name) - return - except Exception as move_exc: - logger.info("imap_delete_mail: UID MOVE not supported, falling back: %s", move_exc) - - # Fallback: COPY to Trash + STORE \Deleted + EXPUNGE - copy_resp = await client.uid('copy', uid_str, trash_folder_name) - if copy_resp.result != 'OK': - raise RuntimeError(f"COPY to Trash failed for mail {mail_id}") - await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') - await client.expunge() - logger.info("imap_delete_mail: moved mail %s (UID %s) to Trash via COPY+DELETE", mail_id, uid_str) - - # Lifecycle hook: mail.after_delete - from app.core.hooks import do_action - await do_action("mail.after_delete", mail_id=str(mail.id), tenant_id=str(tenant_id), permanent=permanent, db=db) - - except Exception as exc: - logger.warning("imap_delete_mail: failed for mail %s: %s", mail_id, exc) - raise - finally: - if client is not None: - try: - await client.logout() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - -# ─── IMAP Move ─── - - -async def imap_move_mail( - db: AsyncSession, - mail_id: uuid.UUID, - target_folder_id: uuid.UUID, - tenant_id: uuid.UUID, -) -> None: - """Move mail to another folder on IMAP server. - - Uses stored imap_uid directly; falls back to Message-ID search if missing. - Uses UID MOVE if supported, otherwise COPY + STORE \\Deleted + EXPUNGE. - Raises exceptions on failure so caller can queue for retry. - """ - import logging - - logger = logging.getLogger(__name__) - - mail = ( - await db.execute( - select(Mail).where( - and_(Mail.id == mail_id, Mail.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not mail: - logger.warning("imap_move_mail: mail %s not found", mail_id) - return - - source_folder = ( - await db.execute( - select(MailFolder).where( - and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not source_folder: - logger.warning("imap_move_mail: source folder not found for mail %s", mail_id) - return - - target_folder = ( - await db.execute( - select(MailFolder).where( - and_(MailFolder.id == target_folder_id, MailFolder.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not target_folder: - logger.warning("imap_move_mail: target folder %s not found", target_folder_id) - return - - account = ( - await db.execute( - select(MailAccount).where( - and_(MailAccount.id == source_folder.account_id, MailAccount.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not account: - logger.warning("imap_move_mail: account not found for mail %s", mail_id) - return - - if not mail.imap_uid and not mail.message_id: - logger.warning("imap_move_mail: mail %s has no imap_uid or message_id, cannot sync", mail_id) - return - - # Lifecycle hook: mail.before_move - from app.core.hooks import do_action - await do_action("mail.before_move", mail_id=str(mail.id), tenant_id=str(tenant_id), source_folder_id=str(source_folder.id), target_folder_id=str(target_folder.id), db=db) - - password = await get_account_password(account) - client = None - - 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) - - select_resp = await client.select(source_folder.imap_name) - if select_resp.result != 'OK': - raise RuntimeError(f"cannot select folder {source_folder.imap_name}") - - # Use stored imap_uid directly; fall back to Message-ID search - if mail.imap_uid: - uid_str = mail.imap_uid - else: - search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"') - 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 = [] - if not uids: - raise RuntimeError(f"no UID found for Message-ID {mail.message_id}") - uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0]) - - # Try UID MOVE first; fall back to COPY + STORE \\Deleted + EXPUNGE - try: - move_resp = await client.uid('move', uid_str, target_folder.imap_name) - if move_resp.result == 'OK': - logger.info("imap_move_mail: moved mail %s (UID %s) via UID MOVE", mail_id, uid_str) - return - except Exception as move_exc: - logger.info("imap_move_mail: UID MOVE not supported, falling back: %s", move_exc) - - # Fallback: COPY + STORE \\Deleted + EXPUNGE - copy_resp = await client.uid('copy', uid_str, target_folder.imap_name) - if copy_resp.result != 'OK': - raise RuntimeError(f"COPY failed for mail {mail_id}") - - await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') - await client.expunge() - - logger.info("imap_move_mail: moved mail %s (UID %s) via COPY+DELETE", mail_id, uid_str) - - # Lifecycle hook: mail.after_move - from app.core.hooks import do_action - await do_action("mail.after_move", mail_id=str(mail.id), tenant_id=str(tenant_id), source_folder_id=str(source_folder.id), target_folder_id=str(target_folder.id), db=db) - - except Exception as exc: - logger.warning("imap_move_mail: failed for mail %s: %s", mail_id, exc) - raise - finally: - if client is not None: - try: - await client.logout() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - -# ─── Draft Save / Update ─── - - -async def save_draft( - db: AsyncSession, - account_id: uuid.UUID, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - data: dict, -) -> Mail: - """Save a new draft mail to DB and IMAP Drafts folder.""" - import logging - - logger = logging.getLogger(__name__) - - # 1. Find the Drafts folder (imap_name contains 'Drafts') - account = ( - await db.execute( - select(MailAccount).where( - and_(MailAccount.id == account_id, MailAccount.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not account: - raise ValueError("Account not found") - - folders = ( - await db.execute( - select(MailFolder).where( - and_(MailFolder.account_id == account_id, MailFolder.tenant_id == tenant_id) - ) - ) - ).scalars().all() - - drafts_folder = None - for f in folders: - if 'draft' in f.imap_name.lower(): - drafts_folder = f - break - - if not drafts_folder: - raise ValueError("No Drafts folder found for this account") - - # 2. Create Mail record - to_str = ', '.join(data.get('to', [])) - cc_str = ', '.join(data.get('cc', [])) - bcc_str = ', '.join(data.get('bcc', [])) - subject = data.get('subject', '') - body_text = data.get('body_text', '') - body_html = data.get('body_html', '') - - msg_id = make_msgid() - now = datetime.now(UTC) - - mail = Mail( - tenant_id=tenant_id, - account_id=account_id, - folder_id=drafts_folder.id, - message_id=msg_id, - thread_id=msg_id, - subject=subject, - from_address=account.email_address, - to_addresses=to_str, - cc_addresses=cc_str, - bcc_addresses=bcc_str, - body_text=body_text, - body_html=body_html, - body_html_sanitized=body_html, - is_seen=True, - is_flagged=False, - is_draft=True, - is_answered=False, - is_forwarded=False, - has_attachments=False, - size_bytes=len(body_text.encode('utf-8')), - received_at=now, - sent_at=None, - ) - db.add(mail) - await db.flush() - - # ── Notification: draft saved ── - try: - await create_notification( - db, tenant_id, user_id, - "mail_draft", - "Entwurf gespeichert", - subject or "Ohne Betreff", - ) - await db.flush() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - # 3. Build RFC822 message and APPEND to IMAP Drafts folder - password = await get_account_password(account) - client = None - 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) - - # Build RFC822 message - email_msg = EmailMessage() - email_msg['From'] = formataddr((account.display_name, account.email_address)) - if to_str: - email_msg['To'] = to_str - if cc_str: - email_msg['Cc'] = cc_str - email_msg['Subject'] = subject - email_msg['Date'] = formatdate(localtime=True) - email_msg['Message-ID'] = msg_id - email_msg.set_content(body_text if body_text else '') - if body_html: - email_msg.add_alternative(body_html, subtype='html') - - rfc822_bytes = email_msg.as_bytes() - - # APPEND to Drafts folder - append_resp = await client.append( - drafts_folder.imap_name, - r'(\\Draft)', - str(int(now.timestamp())), - rfc822_bytes, - ) - if append_resp.result != 'OK': - logger.warning("save_draft: IMAP APPEND failed for drafts folder %s", drafts_folder.imap_name) - else: - logger.info("save_draft: appended draft %s to IMAP Drafts", mail.id) - - except Exception as exc: - logger.warning("save_draft: IMAP append failed (non-critical): %s", exc) - finally: - if client is not None: - try: - await client.logout() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - return mail - - -async def update_draft( - db: AsyncSession, - mail_id: uuid.UUID, - tenant_id: uuid.UUID, - data: dict, -) -> Mail: - """Update an existing draft.""" - import logging - - logger = logging.getLogger(__name__) - - mail = ( - await db.execute( - select(Mail).where( - and_(Mail.id == mail_id, Mail.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not mail: - raise ValueError("Mail not found") - if not mail.is_draft: - raise ValueError("Mail is not a draft") - - # 2. Update fields - to_str = ', '.join(data.get('to', [])) - cc_str = ', '.join(data.get('cc', [])) - bcc_str = ', '.join(data.get('bcc', [])) - mail.to_addresses = to_str - mail.cc_addresses = cc_str - mail.bcc_addresses = bcc_str - mail.subject = data.get('subject', '') - mail.body_text = data.get('body_text', '') - mail.body_html = data.get('body_html', '') - mail.body_html_sanitized = data.get('body_html', '') - mail.size_bytes = len(mail.body_text.encode('utf-8')) - await db.flush() - - # 3. Delete old IMAP copy and APPEND new one - folder = ( - await db.execute( - select(MailFolder).where( - and_(MailFolder.id == mail.folder_id, MailFolder.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not folder: - return mail - - account = ( - await db.execute( - select(MailAccount).where( - and_(MailAccount.id == folder.account_id, MailAccount.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not account: - return mail - - password = await get_account_password(account) - client = None - 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) - - # Delete old copy from IMAP - select_resp = await client.select(folder.imap_name) - if select_resp.result == 'OK' and mail.message_id: - search_resp = await client.uid_search(f'HEADER Message-ID "{mail.message_id}"') - 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 = [] - if uids: - uid_str = uids[0].decode() if isinstance(uids[0], bytes) else str(uids[0]) - await client.uid('store', uid_str, r'+FLAGS (\\Deleted)') - await client.expunge() - - # APPEND new copy - email_msg = EmailMessage() - email_msg['From'] = formataddr((account.display_name, account.email_address)) - if to_str: - email_msg['To'] = to_str - if cc_str: - email_msg['Cc'] = cc_str - email_msg['Subject'] = mail.subject - email_msg['Date'] = formatdate(localtime=True) - email_msg['Message-ID'] = mail.message_id - email_msg.set_content(mail.body_text if mail.body_text else '') - if mail.body_html: - email_msg.add_alternative(mail.body_html, subtype='html') - - rfc822_bytes = email_msg.as_bytes() - now = datetime.now(UTC) - append_resp = await client.append( - folder.imap_name, - r'(\\Draft)', - str(int(now.timestamp())), - rfc822_bytes, - ) - if append_resp.result != 'OK': - logger.warning("update_draft: IMAP APPEND failed for drafts folder %s", folder.imap_name) - else: - logger.info("update_draft: appended updated draft %s to IMAP Drafts", mail.id) - - except Exception as exc: - logger.warning("update_draft: IMAP sync failed (non-critical): %s", exc) - finally: - if client is not None: - try: - await client.logout() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - return mail - - -# ─── IMAP Folder Create / Delete ─── - - -async def imap_create_folder( - db: AsyncSession, account_id: uuid.UUID, folder_name: str, tenant_id: uuid.UUID -) -> None: - """Create folder on IMAP server. - - Connects to IMAP, creates folder with CREATE command. - Non-critical: errors are logged, DB operation still succeeds. - """ - import logging - - logger = logging.getLogger(__name__) - - account = ( - await db.execute( - select(MailAccount).where( - and_(MailAccount.id == account_id, MailAccount.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not account: - logger.warning("imap_create_folder: account %s not found", account_id) - return - - password = await get_account_password(account) - client = None - - 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) - - resp = await client.create(folder_name) - if resp.result != "OK": - logger.warning( - "imap_create_folder: CREATE failed for %s: %s", folder_name, resp - ) - else: - logger.info("imap_create_folder: created folder %s on IMAP", folder_name) - try: - await create_notification( - db, account.tenant_id, account.user_id, - "mail_folder", - "Ordner erstellt", - folder_name, - ) - await db.flush() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - except Exception as exc: - logger.warning("imap_create_folder: failed (non-critical): %s", exc) - finally: - if client is not None: - try: - await client.logout() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - -async def imap_delete_folder( - db: AsyncSession, folder_id: uuid.UUID, tenant_id: uuid.UUID -) -> None: - """Delete folder from IMAP server. - - Connects to IMAP, deletes folder with DELETE command. - Non-critical: errors are logged, DB operation still succeeds. - """ - import logging - - logger = logging.getLogger(__name__) - - folder = ( - await db.execute( - select(MailFolder).where( - and_(MailFolder.id == folder_id, MailFolder.tenant_id == tenant_id) - ) - ) - ).scalar_one_or_none() - if not folder: - logger.warning("imap_delete_folder: folder %s not found", folder_id) - return - - account = ( - await db.execute( - select(MailAccount).where( - and_( - MailAccount.id == folder.account_id, - MailAccount.tenant_id == tenant_id, - ) - ) - ) - ).scalar_one_or_none() - if not account: - logger.warning("imap_delete_folder: account not found for folder %s", folder_id) - return - - password = await get_account_password(account) - client = None - - 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) - - resp = await client.delete(folder.imap_name) - if resp.result != "OK": - logger.warning( - "imap_delete_folder: DELETE failed for %s: %s", folder.imap_name, resp - ) - else: - logger.info("imap_delete_folder: deleted folder %s on IMAP", folder.imap_name) - try: - await create_notification( - db, account.tenant_id, account.user_id, - "mail_folder", - "Ordner gelöscht", - folder.imap_name, - ) - await db.flush() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - except Exception as exc: - logger.warning("imap_delete_folder: failed (non-critical): %s", exc) - finally: - if client is not None: - try: - await client.logout() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - -# ─── Auto-Sync ─── - - -async def auto_sync_all_accounts() -> None: - """Auto-sync all active mail accounts. - - Called periodically by the background scheduler. - Iterates all active mail accounts and syncs each one. - """ - import logging - - from app.core.db import get_session_factory - - logger = logging.getLogger(__name__) - - factory = get_session_factory() - async with factory() as db: - accounts = ( - await db.execute( - select(MailAccount).where(MailAccount.is_active.is_(True)) - ) - ).scalars().all() - - if not accounts: - return - - logger.info("auto_sync_all_accounts: syncing %d active account(s)", len(accounts)) - - for account in accounts: - try: - result = await imap_sync_account(db, account.id, account.tenant_id) - logger.info( - "auto_sync_all_accounts: synced account %s (%s): %s", - account.id, - account.username, - result, - ) - except Exception as exc: - logger.warning( - "auto_sync_all_accounts: failed for account %s: %s", - account.id, - exc, - ) - try: - await create_notification( - db, account.tenant_id, account.user_id, - "mail_sync_error", - "Synchronisierung fehlgeschlagen", - f"Account {account.email_address}: {exc}", - ) - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - # commit per-account so partial progress is saved - try: - await db.commit() - except Exception: - await db.rollback() - - -async def process_sync_queue(db: AsyncSession) -> None: - """Process pending IMAP operations from the sync queue. - - Called at the start of each auto-sync loop iteration. - Retries failed delete/move operations. - """ - import logging - - logger = logging.getLogger(__name__) - - from app.plugins.builtins.mail.models import MailSyncQueue - - pending = ( - await db.execute( - select(MailSyncQueue).where( - and_( - MailSyncQueue.status == "pending", - MailSyncQueue.attempts < MailSyncQueue.max_attempts, - ) - ) - ) - ).scalars().all() - - if not pending: - return - - logger.info("process_sync_queue: processing %d pending operation(s)", len(pending)) - - for entry in pending: - try: - if entry.operation == "delete": - is_permanent = entry.payload.get("permanent", False) if entry.payload else False - await imap_delete_mail(db, entry.mail_id, entry.tenant_id, permanent=is_permanent) - elif entry.operation == "move": - target_folder_id = uuid.UUID(entry.payload.get("target_folder_id", "")) - await imap_move_mail(db, entry.mail_id, target_folder_id, entry.tenant_id) - else: - logger.warning("process_sync_queue: unknown operation %s", entry.operation) - entry.status = "failed" - entry.last_error = f"Unknown operation: {entry.operation}" - continue - - entry.status = "completed" - entry.updated_at = datetime.now(UTC) - logger.info("process_sync_queue: completed %s for mail %s", entry.operation, entry.mail_id) - - except Exception as exc: - entry.attempts += 1 - entry.last_error = str(exc) - entry.updated_at = datetime.now(UTC) - if entry.attempts >= entry.max_attempts: - entry.status = "failed" - logger.warning( - "process_sync_queue: giving up on %s for mail %s after %d attempts: %s", - entry.operation, entry.mail_id, entry.attempts, exc, - ) - else: - logger.info( - "process_sync_queue: retry %d/%d for %s mail %s: %s", - entry.attempts, entry.max_attempts, entry.operation, entry.mail_id, exc, - ) - - await db.flush() +__all__ = [ + # accounts + "create_mail_account", + "update_mail_account", + "account_to_response", + # crypto + "decrypt_password", + "encrypt_password", + "generate_salt", + # drafts & sync + "auto_sync_all_accounts", + "process_sync_queue", + "save_draft", + "update_draft", + # imap ops + "imap_delete_mail", + "imap_move_mail", + "imap_sync_mail_flags", + # imap sync + "_compute_thread_id", + "_get_german_folder_name", + "imap_sync_account", + "imap_sync_folder", + "get_account_password", + "_parse_imap_list_response", + "_parse_imap_quota_response", + # models + "Mail", + "MailAccount", + "MailAttachment", + "MailFolder", + "MailLabel", + "MailLabelAssignment", + "MailRule", + "MailSignature", + "MailTemplate", + "VacationSentLog", + # pgp + "import_pgp_private_key", + "import_pgp_public_key", + "pgp_decrypt_message", + "pgp_encrypt_message", + # rules & vacation + "apply_rules_to_mail", + "execute_rule_actions", + "log_vacation_sent", + "matches_condition", + "should_send_vacation_reply", + "substitute_template_vars", + # sanitize + "sanitize_html", + # serializers + "attachment_to_response", + "folder_to_response", + "label_to_response", + "rule_to_response", + "signature_to_response", + "template_to_response", + # smtp send + "forward_mail", + "reply_to_mail", + "send_mail_via_smtp", + # text utils + "_strip_html", + "extract_email_addresses", +] diff --git a/tests/conftest.py b/tests/conftest.py index def66f0..a81d083 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -492,7 +492,7 @@ def mock_imap_connections(monkeypatch): return fake_client monkeypatch.setattr( - "app.plugins.builtins.mail.services.aioimaplib.IMAP4_SSL", + "app.plugins.builtins.mail.imap_sync.aioimaplib.IMAP4_SSL", _fake_factory, ) yield fake_client diff --git a/tests/test_mail.py b/tests/test_mail.py index 889055d..420a7fd 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -385,7 +385,7 @@ async def test_send_mail(mail_authed_client): """AC: POST /api/v1/mail/send -> 200, mail sent via SMTP.""" client, seed = mail_authed_client account = await _create_account(client) - with patch("app.plugins.builtins.mail.services.aiosmtplib.SMTP") as mock_smtp_class: + with patch("app.plugins.builtins.mail.smtp_send.aiosmtplib.SMTP") as mock_smtp_class: mock_smtp_inst = AsyncMock() mock_smtp_class.return_value = mock_smtp_inst resp = await client.post( @@ -417,7 +417,7 @@ async def test_reply_mail(mail_authed_client, db_session): subject="Original", ) await db_session.commit() - with patch("app.plugins.builtins.mail.services.aiosmtplib.SMTP") as mock_smtp_class: + with patch("app.plugins.builtins.mail.smtp_send.aiosmtplib.SMTP") as mock_smtp_class: mock_smtp_class.return_value = AsyncMock() resp = await client.post( f"/api/v1/mail/{mail.id}/reply", @@ -442,7 +442,7 @@ async def test_forward_mail(mail_authed_client, db_session): subject="Forward Me", ) await db_session.commit() - with patch("app.plugins.builtins.mail.services.aiosmtplib.SMTP") as mock_smtp_class: + with patch("app.plugins.builtins.mail.smtp_send.aiosmtplib.SMTP") as mock_smtp_class: mock_smtp_class.return_value = AsyncMock() resp = await client.post( f"/api/v1/mail/{mail.id}/forward",