- Invitations: each user can invite two friends by email (sign-up works through the link even while registration is closed); admin-editable template, subject and sender. - Invitation sender defaults to the system-mail From address, which the SMTP account is allowed to send as (was postmaster@APP_DOMAIN, refused with 553 when the two differ). - Sign-in, sign-up and forgot-password fields show the fixed @domain after the typed name; the registration status reports it. - System mail gets a Date header; the invitation's plain-text part keeps its paragraphs and line breaks. - SMTP EHLO uses SMTP_HELO_NAME / APP_DOMAIN, not the container name. - DNS recommendations for the mail domains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
5.6 KiB
Python
154 lines
5.6 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.
|
|
|
|
An image written into the HTML itself (``data:image/…;base64,…`` — a signature
|
|
logo pasted into the editor) goes the same way: Gmail and other mail programs
|
|
do not show ``data:`` images in mail they receive, so each becomes an inline
|
|
part too (one per distinct image).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
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 import rawstore
|
|
from app.indexer.sanitize import INTERNAL_CID
|
|
from app.models import Attachment, Message
|
|
|
|
_DATA_IMG = re.compile(r"^data:(image/[a-z0-9.+-]+);base64,(.*)$", re.IGNORECASE | re.DOTALL)
|
|
_EXT = {"jpeg": "jpg", "svg+xml": "svg"}
|
|
|
|
_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 data_image(src: str, domain: str) -> InlinePart | None:
|
|
"""The inline part for a ``data:image/…;base64`` URL, or None if it is not one."""
|
|
m = _DATA_IMG.match(src.strip())
|
|
if m is None:
|
|
return None
|
|
try:
|
|
data = base64.b64decode(re.sub(r"\s+", "", m.group(2)), validate=True)
|
|
except (binascii.Error, ValueError):
|
|
return None
|
|
if not data:
|
|
return None
|
|
mime_type = m.group(1).lower()
|
|
subtype = mime_type.split("/", 1)[1]
|
|
name = f"image.{_EXT.get(subtype, subtype)}"
|
|
return InlinePart(make_msgid(domain=domain)[1:-1], mime_type, name, data)
|
|
|
|
|
|
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] = {}
|
|
# Images written into the HTML, by content: the same logo twice is one part.
|
|
data_parts: dict[str, InlinePart | None] = {}
|
|
|
|
def data_key(src: str) -> str:
|
|
return hashlib.sha256(src.strip().encode()).hexdigest()
|
|
|
|
for match in _IMG.finditer(html):
|
|
src = match.group(2)
|
|
if src.strip().lower().startswith("data:"):
|
|
key = data_key(src)
|
|
if key not in data_parts:
|
|
data_parts[key] = data_image(src, domain)
|
|
continue
|
|
target = internal_cid(src)
|
|
if target is None or target in parts:
|
|
continue
|
|
message_id, content_id = target
|
|
found = (
|
|
await session.execute(
|
|
select(Attachment, Message)
|
|
.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 = found
|
|
try:
|
|
await rawstore.ensure(message) # a hosted mailbox's: fetched if need be
|
|
data = await asyncio.to_thread(
|
|
att_store.load, user_id, message.blob_sha256, 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:
|
|
quote = match.group(1)
|
|
src = match.group(2)
|
|
if src.strip().lower().startswith("data:"):
|
|
data_part = data_parts.get(data_key(src))
|
|
if data_part is None:
|
|
return match.group(0) # not an image we can carry: left as it was
|
|
return match.group(0).replace(
|
|
f"src={quote}{src}{quote}", f"src={quote}cid:{data_part.cid}{quote}", 1
|
|
)
|
|
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
|
|
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 or data_parts else html
|
|
quoted = [p for p in parts.values() if p is not None]
|
|
return out, quoted + [p for p in data_parts.values() if p is not None]
|