Files
webmail-client/backend/app/outbox/inline_images.py
T
lukasandClaude Opus 5.5 0514053252 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>
2026-10-08 20:20:21 +02:00

104 lines
3.5 KiB
Python

"""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]