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,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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+123
-1317
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user