"""Send an email *as the user*, from one of their own accounts (SPEC §11 SDK). Heavy plugins (calendar iMIP invites, later contacts) need to send a message through the organizer's real mailbox — reusing the same SMTP path as the compose outbox, but with a plugin-built body (e.g. a ``text/calendar; method=REQUEST`` part). This helper looks up the user's account, assembles the MIME, and sends via the account's provider. It is exposed as ``sdk(name).mail`` so plugins never touch ``app.providers`` / ``app.sync`` directly. """ from __future__ import annotations import uuid from datetime import UTC, datetime from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import format_datetime, make_msgid from sqlalchemy import select from app.db import session_scope from app.models import Account from app.sync.provider_factory import build_provider class PluginMailError(Exception): """No usable account, or the send failed.""" class PluginMail: """Send outbound mail from one of a user's own accounts.""" async def send( self, user_id: uuid.UUID, *, to: list[str], subject: str, html: str, text: str, from_email: str | None = None, ics: str | None = None, ics_method: str = "REQUEST", ics_filename: str = "invite.ics", ) -> None: """Send an email to ``to`` from the user's account. ``from_email`` selects which of the user's accounts to send from (falls back to their first account). When ``ics`` is given it is added both as a ``text/calendar; method=`` alternative (so the recipient's client shows its native RSVP UI) and as a downloadable attachment. """ recipients = [r for r in (to or []) if r] if not recipients: return async with session_scope() as session: account = None if from_email: account = await session.scalar( select(Account).where( Account.user_id == user_id, Account.email == from_email, ) ) if account is None: account = await session.scalar( select(Account).where(Account.user_id == user_id).order_by(Account.created_at) ) if account is None: raise PluginMailError("no account to send from") raw = _build_message( account, recipients, subject, html, text, ics, ics_method, ics_filename ) envelope_from = account.email provider = build_provider(account) await provider.send(raw, envelope_from, recipients) def _build_message( account: Account, to: list[str], subject: str, html: str, text: str, ics: str | None, ics_method: str, ics_filename: str, ) -> bytes: root = MIMEMultipart("mixed") from_name = account.display_name or "" root["From"] = f"{from_name} <{account.email}>" if from_name else account.email root["To"] = ", ".join(to) root["Subject"] = subject root["Date"] = format_datetime(datetime.now(UTC)) root["Message-ID"] = make_msgid(domain=account.email.rsplit("@", 1)[-1]) alt = MIMEMultipart("alternative") alt.attach(MIMEText(text, "plain", "utf-8")) alt.attach(MIMEText(html, "html", "utf-8")) if ics is not None: cal = MIMEText(ics, "calendar", "utf-8") cal.set_param("method", ics_method) cal.set_param("component", "VEVENT") alt.attach(cal) root.attach(alt) if ics is not None: att = MIMEApplication(ics.encode("utf-8"), _subtype="ics") att.add_header("Content-Disposition", "attachment", filename=ics_filename) root.attach(att) return root.as_bytes()