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:
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user