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,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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user