Files
webmail-client/backend/app/outbox/mime_build.py
T

103 lines
3.4 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
"""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
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,
) -> tuple[bytes, str, list[str]]:
"""Return ``(raw_rfc822, envelope_from, envelope_to)`` for ``draft``.
``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the
compose Attach action; each is added as a file attachment part.
"""
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 = 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")
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)]
return msg.as_bytes(), account.email, envelope_to