"""Service layer for the Mail plugin: encryption, IMAP sync, SMTP send, rules, vacation, PGP.""" from __future__ import annotations 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 decrypt_password, encrypt_password, generate_salt, ) from app.plugins.builtins.mail.imap_sync import ( # noqa: E402,F401 _compute_thread_id, _get_german_folder_name, _parse_imap_list_response, _parse_imap_quota_response, get_account_password, imap_sync_account, imap_sync_folder, ) from app.plugins.builtins.mail.models import ( Mail, MailAccount, MailFolder, MailLabelAssignment, MailRule, VacationSentLog, ) from app.plugins.builtins.mail.pgp import ( # noqa: E402,F401 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 account_to_response, attachment_to_response, folder_to_response, label_to_response, mail_to_response, rule_to_response, signature_to_response, template_to_response, ) from app.plugins.builtins.mail.smtp_send import ( # noqa: E402,F401 forward_mail, reply_to_mail, send_mail_via_smtp, ) from app.plugins.builtins.mail.text_utils import ( # noqa: E402,F401 _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()