"""Mail commands — send, mark read/unread, delete via Command pattern.""" from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any import redis.asyncio as aioredis from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.commands.base import BaseCommand, CommandResult from app.core.outbox import enqueue_outbox_event from app.plugins.builtins.mail.services import sanitize_html logger = logging.getLogger(__name__) class SendMailCommand(BaseCommand): """Send an email via a configured IMAP/SMTP account.""" permission = "mail:send" def __init__(self, account_id: str, to: list[str], subject: str, body_text: str, body_html: str | None = None, cc: list[str] | None = None, in_reply_to: str | None = None): self.account_id = account_id self.to = to self.subject = subject self.body_text = body_text self.body_html = body_html self.cc = cc or [] self.in_reply_to = in_reply_to async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult: from app.plugins.builtins.mail.models import Mail, MailAccount tenant_id = self._tenant_id(current_user) try: account_uuid = uuid.UUID(self.account_id) except ValueError: return CommandResult.fail("Invalid account_id") # Verify account belongs to tenant acct_result = await db.execute( select(MailAccount).where(MailAccount.id == account_uuid, MailAccount.tenant_id == tenant_id) ) account = acct_result.scalar_one_or_none() if account is None: return CommandResult.fail("Mail account not found") # Create mail record mail_id = uuid.uuid4() mail = Mail( id=mail_id, tenant_id=tenant_id, account_id=account_uuid, message_id=f"", from_addr=account.email_address, to_addr=",".join(self.to), cc_addr=",".join(self.cc) if self.cc else None, subject=self.subject, body_text=self.body_text, body_html_sanitized=sanitize_html(self.body_html) if self.body_html else None, direction="outgoing", received_at=datetime.now(UTC), is_read=True, folder="Sent", ) db.add(mail) await db.flush() # Enqueue outbox event for async SMTP send await enqueue_outbox_event(db, tenant_id, "mail.send", { "mail_id": str(mail_id), "account_id": self.account_id, "to": self.to, "subject": self.subject, }) return CommandResult.ok({ "id": str(mail_id), "status": "queued", "to": self.to, "subject": self.subject, }) async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None: from app.core.audit import log_audit await log_audit( db, self._tenant_id(current_user), self._user_id(current_user), action="mail.send", entity_type="mail", changes={"to": self.to, "subject": self.subject}, ) class MarkMailReadCommand(BaseCommand): """Mark a mail as read or unread.""" permission = "mail:write" def __init__(self, mail_id: str, is_read: bool = True): self.mail_id = mail_id self.is_read = is_read async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult: from app.plugins.builtins.mail.models import Mail tenant_id = self._tenant_id(current_user) try: mid = uuid.UUID(self.mail_id) except ValueError: return CommandResult.fail("Invalid mail_id") result = await db.execute( select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id) ) mail = result.scalar_one_or_none() if mail is None: return CommandResult.fail("Mail not found") mail.is_read = self.is_read await db.flush() return CommandResult.ok({"id": self.mail_id, "is_read": self.is_read}) async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None: from app.core.audit import log_audit await log_audit( db, self._tenant_id(current_user), self._user_id(current_user), action="mail.mark_read", entity_type="mail", changes={"mail_id": self.mail_id, "is_read": self.is_read}, ) class DeleteMailCommand(BaseCommand): """Soft-delete a mail.""" permission = "mail:delete" def __init__(self, mail_id: str): self.mail_id = mail_id async def run(self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any]) -> CommandResult: from app.plugins.builtins.mail.models import Mail tenant_id = self._tenant_id(current_user) try: mid = uuid.UUID(self.mail_id) except ValueError: return CommandResult.fail("Invalid mail_id") result = await db.execute( select(Mail).where(Mail.id == mid, Mail.tenant_id == tenant_id, Mail.deleted_at.is_(None)) ) mail = result.scalar_one_or_none() if mail is None: return CommandResult.fail("Mail not found") mail.deleted_at = datetime.now(UTC) await db.flush() await enqueue_outbox_event(db, tenant_id, "mail.deleted", {"mail_id": self.mail_id}) return CommandResult.ok({"id": self.mail_id, "deleted": True}) async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None: from app.core.audit import log_audit await log_audit( db, self._tenant_id(current_user), self._user_id(current_user), action="mail.delete", entity_type="mail", changes={"mail_id": self.mail_id}, )