"""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)