Invite a friend; handle field shows the domain; system mail fixes
- 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>
This commit is contained in:
@@ -7,11 +7,19 @@ 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
|
||||
@@ -26,6 +34,9 @@ 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)
|
||||
|
||||
|
||||
@@ -37,6 +48,23 @@ class InlinePart:
|
||||
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()
|
||||
@@ -57,9 +85,20 @@ async def embed(
|
||||
"""``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):
|
||||
target = internal_cid(match.group(2))
|
||||
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
|
||||
@@ -90,16 +129,25 @@ async def embed(
|
||||
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
|
||||
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]
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user