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