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.
195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
"""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()
|
|
|
|
|