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.
547 lines
17 KiB
Python
547 lines
17 KiB
Python
"""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()
|