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

135 lines
4.8 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
from app.outbox.inline_images import InlinePart
2026-07-17 23:20:34 +02:00
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.
2026-07-17 23:20:34 +02:00
``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.
2026-07-17 23:20:34 +02:00
"""
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 "")
2026-07-17 23:20:34 +02:00
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",
)
2026-07-17 23:20:34 +02:00
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