"""Assemble an RFC822 message from a draft (SPEC ยง6.5). Builds a multipart/alternative (auto-generated text from HTML), sets threading headers from the message being replied to, and returns the raw bytes plus the SMTP envelope. Attachment assembly is deferred to M2 (attachment upload). """ from __future__ import annotations from datetime import UTC, datetime from email.message import EmailMessage from email.utils import format_datetime, make_msgid import nh3 from app.models import Account, Draft, Message from app.outbox.inline_images import InlinePart def _html_to_text(html: str) -> str: return nh3.clean(html, tags=set(), attributes={}, strip_comments=True) def _addr_list(values: object) -> list[str]: if not isinstance(values, list): return [] out: list[str] = [] for v in values: if isinstance(v, str) and v.strip(): out.append(v.strip()) elif isinstance(v, dict) and v.get("email"): name, email = v.get("name", ""), v["email"] out.append(f"{name} <{email}>" if name else str(email)) return out def _email_only(formatted: str) -> str: if "<" in formatted and ">" in formatted: return formatted.split("<", 1)[1].split(">", 1)[0].strip() return formatted.strip() def _bracket(mid: str) -> str: mid = mid.strip() return mid if mid.startswith("<") else f"<{mid}>" def build_mime( account: Account, draft: Draft, reply_to: Message | None, attachments: list[tuple[str, str, bytes]] | None = None, *, html: str | None = None, inline: list[InlinePart] | None = None, ) -> tuple[bytes, bytes, str, list[str]]: """Return ``(transmit_raw, sent_raw, envelope_from, envelope_to)`` for ``draft``. ``transmit_raw`` is sent to the SMTP recipients and carries NO ``Bcc`` header. ``sent_raw`` is the copy filed in Sent; it additionally includes a ``Bcc`` header so the sender keeps a record of blind recipients (standard client behavior). They are identical when there is no Bcc. ``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the compose Attach action; each is added as a file attachment part. ``html`` replaces the draft's HTML when given (images already turned into ``cid:`` references by app/outbox/inline_images.py), and ``inline`` are the image parts those references name, sent alongside the HTML. """ payload = draft.payload to = _addr_list(payload.get("to")) cc = _addr_list(payload.get("cc")) bcc = _addr_list(payload.get("bcc")) subject = str(payload.get("subject", "")) html = html if html is not None else str(payload.get("html", "") or "") text = str(payload.get("text", "") or "") msg = EmailMessage() from_name = account.display_name or "" msg["From"] = f"{from_name} <{account.email}>" if from_name else account.email if to: msg["To"] = ", ".join(to) if cc: msg["Cc"] = ", ".join(cc) msg["Subject"] = subject msg["Date"] = format_datetime(datetime.now(UTC)) domain = account.email.rsplit("@", 1)[-1] msg["Message-ID"] = make_msgid(domain=domain) # Threading headers so the recipient's client threads the reply (SPEC M1). if reply_to is not None and reply_to.message_id_hdr: parent = _bracket(reply_to.message_id_hdr) msg["In-Reply-To"] = parent refs = [_bracket(r) for r in reply_to.references_hdr] + [parent] msg["References"] = " ".join(refs) # Body: text + HTML alternative (auto text when only HTML given). if html: msg.set_content(text or _html_to_text(html)) msg.add_alternative(html, subtype="html") if inline: html_part = next(p for p in msg.walk() if p.get_content_type() == "text/html") assert isinstance(html_part, EmailMessage) for part in inline: maintype, _, subtype = part.mime_type.partition("/") html_part.add_related( part.data, maintype=maintype or "image", subtype=subtype or "png", cid=f"<{part.cid}>", filename=part.filename, disposition="inline", ) else: msg.set_content(text) for filename, mime_type, data in attachments or []: maintype, _, subtype = mime_type.partition("/") msg.add_attachment( data, maintype=maintype or "application", subtype=subtype or "octet-stream", filename=filename, ) envelope_to = [_email_only(a) for a in (to + cc + bcc)] transmit_raw = msg.as_bytes() # no Bcc header -> safe to transmit if bcc: msg["Bcc"] = ", ".join(bcc) sent_raw = msg.as_bytes() else: sent_raw = transmit_raw return transmit_raw, sent_raw, account.email, envelope_to