Messages size at once; quoted inline images travel inside the reply

The message frame is set up as soon as its document is parsed instead of at
its load event, which waits for every image (remote ones included): it no
longer sits at its first height until the slowest image arrives.

A reply quoted an original's inline (cid:) images as this server's own URLs,
and they were sent like that — broken for the recipient, and mangled copies
("http:///api/…") made browsers look up a host named "api". Outgoing mail
(and the IMAP Drafts copy) now carries them as inline parts with new
Content-IDs; one that can no longer be had is left out. Shown messages load
such URLs from this server (sanitizer v5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:20:21 +02:00
co-authored by Claude Opus 5.5
parent d0caf76b03
commit 0514053252
6 changed files with 317 additions and 7 deletions
+22 -1
View File
@@ -14,6 +14,7 @@ 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:
@@ -49,6 +50,9 @@ def build_mime(
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``.
@@ -59,13 +63,17 @@ def build_mime(
``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 = str(payload.get("html", "") or "")
html = html if html is not None else str(payload.get("html", "") or "")
text = str(payload.get("text", "") or "")
msg = EmailMessage()
@@ -91,6 +99,19 @@ def build_mime(
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)