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
+13 -1
View File
@@ -17,7 +17,12 @@ import nh3
# Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a
# render re-sanitizes stale content from the raw blob (SPEC §10.4).
SANITIZER_VERSION = 4
# v5: this server's own inline-image URLs quoted in a message load from here.
SANITIZER_VERSION = 5
# This server's inline-image URL, however it ended up written: "/api/…",
# "https://host/api/…", or mangled to "http:///api/…" by an earlier copy.
INTERNAL_CID = re.compile(r"/api/messages/([0-9a-fA-F-]{36})/cid/([^\"'?#\s<>]+)")
_ALLOWED_TAGS: set[str] = {
# structure
@@ -202,6 +207,13 @@ def sanitize_html(
if lowered.startswith("cid:"):
content_id = value.strip()[4:]
return f"/api/messages/{message_id}/cid/{content_id}"
# This server's own inline-image URL, quoted from another message
# (by an earlier version, or another of the user's own mails) —
# possibly mangled to "http:///api/…", which a browser takes for a
# host named "api": load it from here, never look that host up.
internal = INTERNAL_CID.search(value)
if internal is not None and not lowered.startswith("data:"):
return f"/api/messages/{internal.group(1)}/cid/{internal.group(2)}"
if lowered.startswith(("http:", "https:")):
state["has_remote"] = True
return value if allow_remote_images else None # block by default (FR-23)
+10 -1
View File
@@ -17,6 +17,7 @@ from sqlalchemy import select
from app.db import session_scope
from app.models import Account, Draft, Mailbox, Message
from app.outbox import inline_images
from app.outbox.attachments import load_attachments
from app.outbox.mime_build import build_mime
from app.sync.provider_factory import build_provider
@@ -68,7 +69,15 @@ async def push_draft_to_imap_job(ctx: dict[str, Any], draft_id: str) -> None:
else None
)
attachments = await load_attachments(session, user_id, draft)
_, sent_raw, _, _ = build_mime(account, draft, reply_to, attachments)
html, inline = await inline_images.embed(
session,
user_id,
str(draft.payload.get("html", "") or ""),
account.email.rsplit("@", 1)[-1],
)
_, sent_raw, _, _ = build_mime(
account, draft, reply_to, attachments, html=html, inline=inline
)
drafts_mb = await session.scalar(
select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.special_use == "drafts")
)
+103
View File
@@ -0,0 +1,103 @@
"""Inline images in an outgoing message's HTML.
A reply quotes the original as the reading pane shows it, so an image embedded
in the original (``cid:`` — a signature logo, a pasted screenshot) appears in
the quote as this server's own URL, ``/api/messages/{id}/cid/{content-id}``.
Sent as is, that URL means nothing to the recipient (and to anyone else,
including ourselves once the original is gone). Before sending, each such image
is put back into the message as an inline part with a new Content-ID — as any
mail program does — and one that can no longer be had is left out.
"""
from __future__ import annotations
import asyncio
import re
import uuid
from dataclasses import dataclass
from email.utils import make_msgid
from urllib.parse import unquote
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.indexer import attachments as att_store
from app.indexer.sanitize import INTERNAL_CID
from app.models import Attachment, Message
_IMG = re.compile(r"<img\b[^>]*?\bsrc\s*=\s*([\"'])(.*?)\1[^>]*>", re.IGNORECASE | re.DOTALL)
@dataclass(frozen=True)
class InlinePart:
cid: str # without angle brackets
mime_type: str
filename: str
data: bytes
def internal_cid(src: str) -> tuple[uuid.UUID, str] | None:
"""The (message id, content id) an image URL of this server points at."""
value = src.strip()
if value.lower().startswith(("cid:", "data:")):
return None
m = INTERNAL_CID.search(value)
if m is None:
return None
try:
return uuid.UUID(m.group(1)), unquote(m.group(2))
except ValueError:
return None
async def embed(
session: AsyncSession, user_id: uuid.UUID, html: str, domain: str
) -> tuple[str, list[InlinePart]]:
"""``html`` with this server's image URLs turned into ``cid:`` references,
and the parts to send them in."""
parts: dict[tuple[uuid.UUID, str], InlinePart | None] = {}
for match in _IMG.finditer(html):
target = internal_cid(match.group(2))
if target is None or target in parts:
continue
message_id, content_id = target
found = (
await session.execute(
select(Attachment, Message.blob_sha256)
.join(Message, Message.id == Attachment.message_id)
.where(
Attachment.message_id == message_id,
Attachment.content_id == content_id,
Message.user_id == user_id,
)
.limit(1)
)
).first()
part: InlinePart | None = None
if found is not None:
att, message_sha = found
try:
data = await asyncio.to_thread(
att_store.load, user_id, message_sha, att.blob_sha256, att.part_index
)
cid = make_msgid(domain=domain)[1:-1]
part = InlinePart(cid, att.mime_type, att.filename, data)
except FileNotFoundError:
part = None
parts[target] = part
def replace(match: re.Match[str]) -> str:
target = internal_cid(match.group(2))
if target is None:
return match.group(0)
part = parts.get(target)
if part is None:
return "" # gone: no broken image for the recipient
quote = match.group(1)
return match.group(0).replace(
f"src={quote}{match.group(2)}{quote}", f"src={quote}cid:{part.cid}{quote}", 1
)
out = _IMG.sub(replace, html) if parts else html
return out, [p for p in parts.values() if p is not None]
+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)
+133
View File
@@ -0,0 +1,133 @@
"""Images quoted from another message go out inside the message, not as links
to this server; such links already sent load from here when shown."""
from __future__ import annotations
import email
import io
import shutil
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from email import policy
from email.message import EmailMessage
from pathlib import Path
from types import SimpleNamespace
import pytest_asyncio
from PIL import Image
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.indexer.indexer import persist_message
from app.indexer.sanitize import sanitize_html
from app.models import Account, Mailbox, User
from app.outbox import inline_images
from app.outbox.mime_build import build_mime
from app.providers.base import RemoteMessage
def _png() -> bytes:
out = io.BytesIO()
Image.new("RGB", (40, 20), (10, 120, 200)).save(out, "PNG")
return out.getvalue()
LOGO = _png()
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[tuple[AsyncSession, uuid.UUID, uuid.UUID]]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
db = async_sessionmaker(engine, expire_on_commit=False)()
user = User(handle=f"inl{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
db.add(user)
await db.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
db.add(account)
await db.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
db.add(inbox)
await db.commit()
m = EmailMessage()
m["Subject"] = "Signed"
m["From"] = "Ben <ben@example.org>"
m["To"] = "me@example.com"
m["Message-ID"] = "<signed@x>"
m.set_content("hello")
m.add_alternative('<p>hello</p><img src="cid:logo@x">', subtype="html")
m.get_payload()[1].add_related(
LOGO, "image", "png", cid="<logo@x>", filename="logo.png", disposition="inline"
)
remote = RemoteMessage(
uid=1, raw=m.as_bytes(), flags=frozenset(), internal_date=datetime.now(UTC)
)
result = await persist_message(
db,
user_id=user.id,
account_id=account.id,
mailbox_id=inbox.id,
mailbox_special_use="inbox",
remote=remote,
)
await db.commit()
assert result is not None
try:
yield db, user.id, result.message.id
finally:
await db.execute(delete(User).where(User.id == user.id))
await db.commit()
await db.close()
await engine.dispose()
shutil.rmtree(Path(get_settings().blob_root) / str(user.id), ignore_errors=True)
async def test_quoted_images_travel_inside_the_reply(ctx) -> None: # type: ignore[no-untyped-def]
db, user_id, original = ctx
gone = uuid.uuid4()
html = (
"<p>Thanks!</p><blockquote>"
f'<img src="/api/messages/{original}/cid/logo@x" alt="logo">'
f'<img src="http:///api/messages/{original}/cid/logo@x">' # same image, mangled
f'<img src="/api/messages/{gone}/cid/old@x">' # its message is gone
'<img src="https://example.com/banner.png"></blockquote>'
)
out, parts = await inline_images.embed(db, user_id, html, "example.com")
assert len(parts) == 1 and parts[0].data == LOGO
assert out.count(f'src="cid:{parts[0].cid}"') == 2
assert "/api/messages/" not in out and 'alt="logo"' in out
assert "https://example.com/banner.png" in out # other images untouched
account = SimpleNamespace(email="me@example.com", display_name="Me")
draft = SimpleNamespace(
payload={"to": ["ben@example.org"], "subject": "Re: Signed", "html": html}
)
raw, _, _, _ = build_mime(account, draft, None, [], html=out, inline=parts) # type: ignore[arg-type]
sent = email.message_from_bytes(raw, policy=policy.default)
related = [p for p in sent.walk() if p.get_content_type() == "multipart/related"]
assert related, "the HTML and its image belong together"
image = next(p for p in sent.walk() if p.get_content_type() == "image/png")
assert image["Content-ID"] == f"<{parts[0].cid}>"
assert image.get_payload(decode=True) == LOGO
def test_links_already_sent_load_from_here() -> None:
mid = uuid.uuid4()
for src in (
f"http:///api/messages/{mid}/cid/logo@x",
f"https://mail.example.com/api/messages/{mid}/cid/logo@x",
f"/api/messages/{mid}/cid/logo@x",
):
result = sanitize_html(f'<img src="{src}">', uuid.uuid4(), allow_remote_images=True)
assert f'src="/api/messages/{mid}/cid/logo@x"' in result.html, src
assert not result.has_remote_images