Files
leocrm/app/plugins/builtins/mail/drafts_sync.py
T

547 lines
17 KiB
Python
Raw Normal View History

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