104 lines
3.5 KiB
Python
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]
|