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