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:
Agent Zero
2026-08-26 14:33:30 +02:00
parent c34715574a
commit ea6c9e71db
7 changed files with 1434 additions and 1321 deletions
+115
View File
@@ -0,0 +1,115 @@
"""Mail Account Service 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 sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.mail.crypto import encrypt_password, generate_salt
from app.plugins.builtins.mail.models import (
MailAccount,
MailFolder,
)
logger = logging.getLogger(__name__)
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
+546
View File
@@ -0,0 +1,546 @@
"""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()
+452
View File
@@ -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)
+194
View File
@@ -0,0 +1,194 @@
"""Rules, Templates & Vacation 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 json
import logging
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.mail.models import (
Mail,
MailFolder,
MailLabelAssignment,
MailRule,
VacationSentLog,
)
logger = logging.getLogger(__name__)
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()
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -492,7 +492,7 @@ def mock_imap_connections(monkeypatch):
return fake_client
monkeypatch.setattr(
"app.plugins.builtins.mail.services.aioimaplib.IMAP4_SSL",
"app.plugins.builtins.mail.imap_sync.aioimaplib.IMAP4_SSL",
_fake_factory,
)
yield fake_client
+3 -3
View File
@@ -385,7 +385,7 @@ async def test_send_mail(mail_authed_client):
"""AC: POST /api/v1/mail/send -> 200, mail sent via SMTP."""
client, seed = mail_authed_client
account = await _create_account(client)
with patch("app.plugins.builtins.mail.services.aiosmtplib.SMTP") as mock_smtp_class:
with patch("app.plugins.builtins.mail.smtp_send.aiosmtplib.SMTP") as mock_smtp_class:
mock_smtp_inst = AsyncMock()
mock_smtp_class.return_value = mock_smtp_inst
resp = await client.post(
@@ -417,7 +417,7 @@ async def test_reply_mail(mail_authed_client, db_session):
subject="Original",
)
await db_session.commit()
with patch("app.plugins.builtins.mail.services.aiosmtplib.SMTP") as mock_smtp_class:
with patch("app.plugins.builtins.mail.smtp_send.aiosmtplib.SMTP") as mock_smtp_class:
mock_smtp_class.return_value = AsyncMock()
resp = await client.post(
f"/api/v1/mail/{mail.id}/reply",
@@ -442,7 +442,7 @@ async def test_forward_mail(mail_authed_client, db_session):
subject="Forward Me",
)
await db_session.commit()
with patch("app.plugins.builtins.mail.services.aiosmtplib.SMTP") as mock_smtp_class:
with patch("app.plugins.builtins.mail.smtp_send.aiosmtplib.SMTP") as mock_smtp_class:
mock_smtp_class.return_value = AsyncMock()
resp = await client.post(
f"/api/v1/mail/{mail.id}/forward",