refactor(i-g): BUG-018 Pilot Split Schritt 5 — mail/services.py komplett zur Fassade reduziert (-95%)
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user