ea6c9e71db
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.
453 lines
16 KiB
Python
453 lines
16 KiB
Python
"""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)
|
|
|
|
|