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:
@@ -14,6 +14,7 @@ import aiosmtplib
|
||||
|
||||
from app.accounts.ssrf import SsrfError, assert_host_is_public
|
||||
from app.accounts.types import ServerConfig
|
||||
from app.config import smtp_helo_name
|
||||
from app.providers.base import ProviderAuthError, ProviderConnectError
|
||||
from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig
|
||||
|
||||
@@ -53,6 +54,7 @@ async def _test_smtp(config: ServerConfig, username: str, password: str) -> None
|
||||
use_tls=config.tls == "ssl",
|
||||
start_tls=config.tls == "starttls",
|
||||
timeout=_SMTP_TIMEOUT,
|
||||
local_hostname=smtp_helo_name(),
|
||||
)
|
||||
try:
|
||||
await smtp.connect()
|
||||
|
||||
@@ -16,6 +16,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.avatar import router as avatar_router
|
||||
from app.api.compose import router as compose_router
|
||||
from app.api.invitations import router as invitations_router
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
from app.api.messages import router as messages_router
|
||||
from app.api.plugins import router as plugins_router
|
||||
@@ -46,6 +47,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
admin_router,
|
||||
spellcheck_router,
|
||||
push_router,
|
||||
invitations_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
|
||||
+122
-1
@@ -9,7 +9,9 @@ their own account, so the instance can never lock out its last admin that way.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import re
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
@@ -18,11 +20,24 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.hosted.admin import MailServerError
|
||||
from app.identity import invitations
|
||||
from app.identity.deps import CurrentAdmin, require_csrf
|
||||
from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail
|
||||
from app.identity.mailer import (
|
||||
LOGO_CID,
|
||||
LOGO_DATA_URL,
|
||||
LOGO_HTML,
|
||||
MailerError,
|
||||
mailer_config,
|
||||
send_mail,
|
||||
)
|
||||
from app.identity.schemas import (
|
||||
AdminInvitePreviewIn,
|
||||
AdminInvitePreviewOut,
|
||||
AdminInviteTemplateIn,
|
||||
AdminInviteTemplateOut,
|
||||
AdminMailIn,
|
||||
AdminMailOut,
|
||||
AdminMailTestIn,
|
||||
@@ -46,6 +61,7 @@ def _out(row: AppSettings) -> AdminSettingsOut:
|
||||
return AdminSettingsOut(
|
||||
registration_open=row.registration_open,
|
||||
default_quota_bytes=row.default_quota_bytes,
|
||||
invitations_enabled=row.invitations_enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +84,8 @@ async def write_settings(
|
||||
row.registration_open = body.registration_open
|
||||
if body.default_quota_bytes is not None:
|
||||
row.default_quota_bytes = body.default_quota_bytes
|
||||
if body.invitations_enabled is not None:
|
||||
row.invitations_enabled = body.invitations_enabled
|
||||
row.updated_at = dt.datetime.now(dt.UTC)
|
||||
await db.commit()
|
||||
return _out(row)
|
||||
@@ -141,6 +159,109 @@ async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -
|
||||
return MessageOut()
|
||||
|
||||
|
||||
# --- Invitation email ------------------------------------------------------------
|
||||
|
||||
_EMAIL_RE = re.compile(r"[^@\s<>\"]+@[^@\s<>\"]+\.[^@\s<>\"]+")
|
||||
_SAMPLE = {"invitee_name": "Anna Nowak", "inviter_name": "Jan Kowalski"}
|
||||
|
||||
|
||||
def _template_out(row: AppSettings) -> AdminInviteTemplateOut:
|
||||
return AdminInviteTemplateOut(
|
||||
subject=row.invite_subject,
|
||||
html=row.invite_template,
|
||||
from_address=row.invite_from,
|
||||
default_subject=invitations.DEFAULT_SUBJECT,
|
||||
default_html=invitations.DEFAULT_TEMPLATE,
|
||||
default_from=row.smtp_from,
|
||||
placeholders=list(invitations.PLACEHOLDERS),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/invitations/template", response_model=AdminInviteTemplateOut)
|
||||
async def read_invite_template(admin: CurrentAdmin, db: DbSession) -> AdminInviteTemplateOut:
|
||||
return _template_out(await app_settings(db))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/invitations/template",
|
||||
response_model=AdminInviteTemplateOut,
|
||||
dependencies=[Depends(require_csrf)],
|
||||
)
|
||||
async def write_invite_template(
|
||||
body: AdminInviteTemplateIn, admin: CurrentAdmin, db: DbSession
|
||||
) -> AdminInviteTemplateOut:
|
||||
"""Upload the invitation email's HTML template, subject and sender."""
|
||||
row = await app_settings(db, for_update=True)
|
||||
if body.html is not None:
|
||||
html = body.html.strip()
|
||||
if html and (problem := invitations.template_problem(html)):
|
||||
raise_api_error(400, "invalid_template", problem)
|
||||
row.invite_template = html
|
||||
if body.subject is not None:
|
||||
row.invite_subject = " ".join(body.subject.split())
|
||||
if body.from_address is not None:
|
||||
sender = body.from_address.strip()
|
||||
if sender and not _EMAIL_RE.fullmatch(sender):
|
||||
raise_api_error(400, "invalid_email", "the sender must be an email address")
|
||||
row.invite_from = sender
|
||||
row.updated_at = dt.datetime.now(dt.UTC)
|
||||
await db.commit()
|
||||
return _template_out(row)
|
||||
|
||||
|
||||
def _sample(row: AppSettings, subject: str, html: str) -> invitations.RenderedMail:
|
||||
"""The invitation as a friend would get it, with sample names."""
|
||||
if html.strip() and (problem := invitations.template_problem(html)):
|
||||
raise_api_error(400, "invalid_template", problem)
|
||||
draft = AppSettings(invite_subject=subject, invite_template=html.strip())
|
||||
base = row.public_url.rstrip("/") or f"https://{get_settings().app_domain}"
|
||||
return invitations.render(draft, link=f"{base}/register#invite=sample", **_SAMPLE)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/invitations/preview",
|
||||
response_model=AdminInvitePreviewOut,
|
||||
dependencies=[Depends(require_csrf)],
|
||||
)
|
||||
async def preview_invite(
|
||||
body: AdminInvitePreviewIn, admin: CurrentAdmin, db: DbSession
|
||||
) -> AdminInvitePreviewOut:
|
||||
"""Render a template (saved or not) with sample names, for the panel to show."""
|
||||
mail = _sample(await app_settings(db), body.subject, body.html)
|
||||
html = mail.html
|
||||
# The preview frame has no message parts: show the images it would carry.
|
||||
for part in mail.inline:
|
||||
data = base64.b64encode(part.data).decode()
|
||||
html = html.replace(f"cid:{part.cid}", f"data:{part.mime_type};base64,{data}")
|
||||
html = html.replace(f"cid:{LOGO_CID}", LOGO_DATA_URL)
|
||||
return AdminInvitePreviewOut(subject=mail.subject, html=html)
|
||||
|
||||
|
||||
@router.post("/invitations/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
||||
async def test_invite(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
|
||||
"""Send the saved invitation email, with sample names and a dummy link."""
|
||||
row = await app_settings(db)
|
||||
cfg = mailer_config(row)
|
||||
if cfg is None:
|
||||
raise_api_error(
|
||||
409, "mail_not_configured", "set the SMTP server, From address and public address first"
|
||||
)
|
||||
mail = _sample(row, row.invite_subject, row.invite_template)
|
||||
try:
|
||||
await send_mail(
|
||||
cfg,
|
||||
body.to.strip(),
|
||||
mail.subject,
|
||||
mail.text,
|
||||
mail.html,
|
||||
sender=invitations.sender_address(row),
|
||||
inline=mail.inline,
|
||||
)
|
||||
except MailerError as exc:
|
||||
raise_api_error(502, "mail_failed", f"sending failed: {exc}")
|
||||
return MessageOut()
|
||||
|
||||
|
||||
# --- Users --------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Invite a friend (app/identity/invitations.py): what the sidebar entry and its
|
||||
dialog need — how many invitations are left, and sending one."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.identity import invitations
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.identity.mailer import MailerError, mailer_config, send_mail
|
||||
from app.identity.service import app_settings
|
||||
from app.models import Invitation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/invitations", tags=["invitations"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_EMAIL_RE = re.compile(r"[^@\s<>\"]+@[^@\s<>\"]+\.[^@\s<>\"]+")
|
||||
|
||||
|
||||
class SentInvitationOut(BaseModel):
|
||||
invitee_name: str
|
||||
invitee_email: str
|
||||
created_at: str
|
||||
used: bool
|
||||
expired: bool
|
||||
|
||||
|
||||
class InvitationsOut(BaseModel):
|
||||
# Invitations are switched on and system mail can send them.
|
||||
available: bool
|
||||
remaining: int
|
||||
per_user: int
|
||||
sent: list[SentInvitationOut]
|
||||
|
||||
|
||||
class InvitationIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
|
||||
|
||||
@router.get("", response_model=InvitationsOut)
|
||||
async def read(user: CurrentUser, db: DbSession) -> InvitationsOut:
|
||||
row = await app_settings(db)
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
return InvitationsOut(
|
||||
available=row.invitations_enabled and mailer_config(row) is not None,
|
||||
remaining=await invitations.remaining(db, user.id),
|
||||
per_user=invitations.PER_USER,
|
||||
sent=[
|
||||
SentInvitationOut(
|
||||
invitee_name=i.invitee_name,
|
||||
invitee_email=i.invitee_email,
|
||||
created_at=i.created_at.isoformat(),
|
||||
used=i.used_at is not None,
|
||||
expired=i.used_at is None and i.expires_at <= now,
|
||||
)
|
||||
for i in await invitations.sent(db, user.id)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=InvitationsOut, dependencies=[Depends(require_csrf)])
|
||||
async def send(body: InvitationIn, user: CurrentUser, db: DbSession) -> InvitationsOut:
|
||||
"""Email a friend a single-use sign-up link. Sent before answering, so the
|
||||
user hears at once if it could not go out (and keeps the invitation)."""
|
||||
name = " ".join(body.name.split())
|
||||
email = body.email.strip()
|
||||
if not name:
|
||||
raise_api_error(400, "invalid_name", "enter your friend's name")
|
||||
if not _EMAIL_RE.fullmatch(email):
|
||||
raise_api_error(400, "invalid_email", "enter a valid email address")
|
||||
row = await app_settings(db)
|
||||
if not row.invitations_enabled:
|
||||
raise_api_error(403, "invitations_disabled", "invitations are switched off")
|
||||
cfg = mailer_config(row)
|
||||
if cfg is None:
|
||||
raise_api_error(503, "mail_not_configured", "the server cannot send email yet")
|
||||
try:
|
||||
invitation, token = await invitations.create(db, user, name=name, email=email)
|
||||
except invitations.InvitationError as exc:
|
||||
raise_api_error(409, exc.code, exc.message)
|
||||
await db.commit()
|
||||
|
||||
# The token rides in the fragment: never sent to a server, so it stays out
|
||||
# of access logs and Referer headers (as with password-reset links).
|
||||
link = f"{cfg.public_url}/register#invite={token}"
|
||||
mail = invitations.render(
|
||||
row,
|
||||
invitee_name=name,
|
||||
inviter_name=await invitations.inviter_name(db, user),
|
||||
link=link,
|
||||
)
|
||||
try:
|
||||
await send_mail(
|
||||
cfg,
|
||||
email,
|
||||
mail.subject,
|
||||
mail.text,
|
||||
mail.html,
|
||||
sender=invitations.sender_address(row),
|
||||
reply_to=user.handle, # an answer reaches the friend who invited
|
||||
inline=mail.inline,
|
||||
)
|
||||
except MailerError as exc:
|
||||
logger.warning("invitation from %s could not be sent: %s", user.handle, exc)
|
||||
# Not sent: the user keeps the invitation.
|
||||
await db.execute(delete(Invitation).where(Invitation.id == invitation.id))
|
||||
await db.commit()
|
||||
raise_api_error(502, "mail_failed", "the invitation could not be sent — try again later")
|
||||
return await read(user, db)
|
||||
@@ -74,6 +74,9 @@ class Settings(BaseSettings):
|
||||
# New passwords are checked against known data breaches (Have I Been Pwned's
|
||||
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
|
||||
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
|
||||
# The name the webmail gives itself when it connects to a mail server to
|
||||
# send (SMTP EHLO). Empty: APP_DOMAIN — not the container's random name.
|
||||
smtp_helo_name: str = Field(default="", alias="SMTP_HELO_NAME")
|
||||
# Auto-purge Trash/Spam messages older than this many days (0 disables).
|
||||
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
|
||||
|
||||
@@ -109,3 +112,9 @@ class Settings(BaseSettings):
|
||||
def get_settings() -> Settings:
|
||||
"""Return the process-wide settings singleton."""
|
||||
return Settings() # values are sourced from the environment
|
||||
|
||||
|
||||
def smtp_helo_name() -> str:
|
||||
"""The name to introduce ourselves with to SMTP servers (EHLO)."""
|
||||
settings = get_settings()
|
||||
return settings.smtp_helo_name.strip() or settings.app_domain
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Invitations: a user sends a friend a single-use sign-up link.
|
||||
|
||||
While the administrator has invitations switched on, every user may send
|
||||
``PER_USER`` of them. A link lets exactly one account be created — even with
|
||||
public registration closed — and works for ``VALID_FOR``; one that ran out
|
||||
unused is handed back to its sender. Only a hash of the link's token is kept.
|
||||
|
||||
The email is built from the administrator's template (or the built-in one)
|
||||
with these placeholders, written ``{{name}}``:
|
||||
|
||||
invitee_name the friend's name, as the sender typed it
|
||||
inviter_name the sender's name (their mail account's display name,
|
||||
else their address)
|
||||
invite_link the sign-up address itself
|
||||
invite_button a ready-made button linking to it
|
||||
logo the Ultimo Email logo
|
||||
|
||||
Names are HTML-escaped where they go into the HTML: they come from users and
|
||||
end up in mail to strangers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import html as html_lib
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
import nh3
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.mailer import LOGO_HTML
|
||||
from app.models import Account, AppSettings, Invitation, User
|
||||
from app.outbox.inline_images import InlinePart, data_image
|
||||
|
||||
PER_USER = 2
|
||||
VALID_FOR = dt.timedelta(days=14)
|
||||
MAX_TEMPLATE_BYTES = 500 * 1024
|
||||
|
||||
PLACEHOLDERS = ("invitee_name", "inviter_name", "invite_link", "invite_button", "logo")
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([a-z_]+)\s*\}\}")
|
||||
_DATA_SRC = re.compile(r"""(\bsrc\s*=\s*)(["'])(data:image/[^"']+)\2""", re.IGNORECASE)
|
||||
|
||||
DEFAULT_SUBJECT = "{{inviter_name}} invites you to Ultimo Email"
|
||||
_BODY = "font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#1f2937;line-height:1.5"
|
||||
_MUTED = "font-size:13px;color:#6b7280"
|
||||
_BUTTON = (
|
||||
"display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;"
|
||||
"padding:10px 18px;border-radius:8px;font-weight:bold"
|
||||
)
|
||||
DEFAULT_TEMPLATE = f"""<!doctype html>
|
||||
<html><body style="{_BODY}">
|
||||
{{{{logo}}}}
|
||||
<p>Hi {{{{invitee_name}}}},</p>
|
||||
<p><b>{{{{inviter_name}}}}</b> invites you to Ultimo Email — private email,
|
||||
calendar and contacts in one place.</p>
|
||||
<p>{{{{invite_button}}}}</p>
|
||||
<p style="{_MUTED}">Or paste this address into your browser:<br>{{{{invite_link}}}}</p>
|
||||
<p style="{_MUTED}">The invitation works once and expires in 14 days. If you
|
||||
weren't expecting it, you can ignore this email.</p>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class InvitationError(Exception):
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderedMail:
|
||||
subject: str
|
||||
text: str
|
||||
html: str
|
||||
inline: list[InlinePart]
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def sender_address(row: AppSettings) -> str:
|
||||
"""The invitation's From. Empty: the system-mail From address, the one the
|
||||
SMTP account is known to be allowed to send as."""
|
||||
return row.invite_from.strip() or row.smtp_from
|
||||
|
||||
|
||||
def template_problem(html: str) -> str | None:
|
||||
"""Why ``html`` cannot be used as the invitation template, or None."""
|
||||
if len(html.encode()) > MAX_TEMPLATE_BYTES:
|
||||
return f"the template is larger than {MAX_TEMPLATE_BYTES // 1024} KB"
|
||||
used = set(_PLACEHOLDER.findall(html))
|
||||
unknown = sorted(used - set(PLACEHOLDERS))
|
||||
if unknown:
|
||||
return "unknown placeholder: " + ", ".join("{{" + u + "}}" for u in unknown)
|
||||
if not used & {"invite_link", "invite_button"}:
|
||||
return "the template must contain {{invite_link}} or {{invite_button}}"
|
||||
return None
|
||||
|
||||
|
||||
def render(row: AppSettings, *, invitee_name: str, inviter_name: str, link: str) -> RenderedMail:
|
||||
"""The invitation email for one friend, from the configured template."""
|
||||
template = row.invite_template or DEFAULT_TEMPLATE
|
||||
subject_template = row.invite_subject or DEFAULT_SUBJECT
|
||||
esc_link = html_lib.escape(link, quote=True)
|
||||
html_values = {
|
||||
"invitee_name": html_lib.escape(invitee_name),
|
||||
"inviter_name": html_lib.escape(inviter_name),
|
||||
"invite_link": f'<a href="{esc_link}">{esc_link}</a>',
|
||||
"invite_button": f'<a href="{esc_link}" style="{_BUTTON}">Accept the invitation</a>',
|
||||
"logo": LOGO_HTML,
|
||||
}
|
||||
text_values = {
|
||||
"invitee_name": invitee_name,
|
||||
"inviter_name": inviter_name,
|
||||
"invite_link": link,
|
||||
"invite_button": link,
|
||||
"logo": "",
|
||||
}
|
||||
|
||||
def fill(source: str, values: dict[str, str]) -> str:
|
||||
return _PLACEHOLDER.sub(lambda m: values.get(m.group(1), m.group(0)), source)
|
||||
|
||||
# An image written into the template itself (data:) goes as an inline part:
|
||||
# Gmail does not show data: images.
|
||||
inline: list[InlinePart] = []
|
||||
|
||||
def to_cid(match: re.Match[str]) -> str:
|
||||
part = data_image(match.group(3), get_settings().app_domain)
|
||||
if part is None:
|
||||
return match.group(0)
|
||||
inline.append(part)
|
||||
return f"{match.group(1)}{match.group(2)}cid:{part.cid}{match.group(2)}"
|
||||
|
||||
html = _DATA_SRC.sub(to_cid, fill(template, html_values))
|
||||
text = fill(template, text_values)
|
||||
# Keep the paragraphs and line breaks the HTML shows once its tags are gone.
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"</(?:p|div|h[1-6]|li|tr|table)\s*>", "\n\n", text, flags=re.IGNORECASE)
|
||||
text = nh3.clean(text, tags=set(), attributes={}, strip_comments=True)
|
||||
text = html_lib.unescape(re.sub(r"\n\s*\n\s*", "\n\n", text)).strip() + "\n"
|
||||
subject = " ".join(fill(subject_template, text_values).split())
|
||||
return RenderedMail(subject=subject, text=text, html=html, inline=inline)
|
||||
|
||||
|
||||
async def inviter_name(db: AsyncSession, user: User) -> str:
|
||||
"""How the friend is told who invited them."""
|
||||
names = (
|
||||
await db.execute(
|
||||
select(Account.email, Account.display_name).where(Account.user_id == user.id)
|
||||
)
|
||||
).all()
|
||||
own = next((n for e, n in names if e.lower() == user.handle.lower() and n), None)
|
||||
return own or next((n for _, n in names if n), None) or user.handle
|
||||
|
||||
|
||||
def _counts(now: dt.datetime): # type: ignore[no-untyped-def]
|
||||
# Used, or still valid: an invitation that expired unused is given back.
|
||||
return or_(Invitation.used_at.isnot(None), Invitation.expires_at > now)
|
||||
|
||||
|
||||
async def remaining(db: AsyncSession, user_id: uuid.UUID) -> int:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
taken = await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Invitation)
|
||||
.where(Invitation.inviter_id == user_id, _counts(now))
|
||||
)
|
||||
return max(0, PER_USER - int(taken or 0))
|
||||
|
||||
|
||||
async def sent(db: AsyncSession, user_id: uuid.UUID) -> list[Invitation]:
|
||||
return list(
|
||||
await db.scalars(
|
||||
select(Invitation)
|
||||
.where(Invitation.inviter_id == user_id)
|
||||
.order_by(Invitation.created_at.desc())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def create(db: AsyncSession, user: User, *, name: str, email: str) -> tuple[Invitation, str]:
|
||||
"""A new invitation from ``user`` (not committed): the row and the token.
|
||||
Raises :class:`InvitationError` when the user has none left."""
|
||||
# One at a time per user, so two quick clicks cannot both take the last one.
|
||||
await db.execute(select(User.id).where(User.id == user.id).with_for_update())
|
||||
if await remaining(db, user.id) <= 0:
|
||||
raise InvitationError("no_invitations_left", "you have no invitations left")
|
||||
token = secrets.token_urlsafe(32)
|
||||
invitation = Invitation(
|
||||
inviter_id=user.id,
|
||||
token_hash=hash_token(token),
|
||||
invitee_name=name,
|
||||
invitee_email=email,
|
||||
expires_at=dt.datetime.now(dt.UTC) + VALID_FOR,
|
||||
)
|
||||
db.add(invitation)
|
||||
await db.flush()
|
||||
return invitation, token
|
||||
|
||||
|
||||
async def find_valid(db: AsyncSession, token: str) -> Invitation | None:
|
||||
"""The unused, unexpired invitation behind ``token``, without using it."""
|
||||
invitation: Invitation | None = await db.scalar(
|
||||
select(Invitation).where(
|
||||
Invitation.token_hash == hash_token(token),
|
||||
Invitation.used_at.is_(None),
|
||||
Invitation.expires_at > dt.datetime.now(dt.UTC),
|
||||
)
|
||||
)
|
||||
return invitation
|
||||
|
||||
|
||||
async def claim(db: AsyncSession, token: str) -> uuid.UUID | None:
|
||||
"""Use the invitation up (in the caller's transaction): its id, or None if
|
||||
it is unknown, used or expired. Atomic — two sign-ups with one link cannot
|
||||
both get it."""
|
||||
return await db.scalar(
|
||||
update(Invitation)
|
||||
.where(
|
||||
Invitation.token_hash == hash_token(token),
|
||||
Invitation.used_at.is_(None),
|
||||
Invitation.expires_at > dt.datetime.now(dt.UTC),
|
||||
)
|
||||
.values(used_at=func.now())
|
||||
.returning(Invitation.id)
|
||||
)
|
||||
|
||||
|
||||
async def record_user(db: AsyncSession, invitation_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
await db.execute(
|
||||
update(Invitation).where(Invitation.id == invitation_id).values(used_by=user_id)
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Outgoing system mail (SPEC §6.1): mail the server sends on its own behalf.
|
||||
|
||||
Today that is only the password-reset link. It goes out through the SMTP
|
||||
Password-reset links, and invitations users send their friends
|
||||
(app/identity/invitations.py). It goes out through the SMTP
|
||||
account the administrator configured in the admin panel (``app_settings``),
|
||||
never through a user's own mail account — the whole point is that the user
|
||||
cannot sign in. The administrator's server is trusted, so unlike user mail
|
||||
@@ -10,19 +11,26 @@ on the LAN).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import html as html_lib
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, make_msgid
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from app.config import smtp_helo_name
|
||||
from app.models import AppSettings
|
||||
from app.security.crypto import decrypt_secret
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.outbox.inline_images import InlinePart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SENDER_NAME = "Ultimo Email"
|
||||
@@ -33,9 +41,9 @@ _TIMEOUT = 20.0
|
||||
# because mail clients (Gmail first) do not display SVG. Rendered at twice the
|
||||
# size it is shown at, for sharp high-DPI screens.
|
||||
_LOGO_FILE = Path(__file__).parent / "assets" / "ultimo-logo.png"
|
||||
_LOGO_CID = "ultimo-logo@ultimo.email"
|
||||
LOGO_CID = "ultimo-logo@ultimo.email"
|
||||
LOGO_HTML = (
|
||||
f'<p style="text-align:center;margin:0 0 24px"><img src="cid:{_LOGO_CID}" '
|
||||
f'<p style="text-align:center;margin:0 0 24px"><img src="cid:{LOGO_CID}" '
|
||||
'width="180" height="47" alt="Ultimo Email" style="display:inline-block;border:0"></p>'
|
||||
)
|
||||
|
||||
@@ -45,6 +53,10 @@ def _logo() -> bytes:
|
||||
return _LOGO_FILE.read_bytes()
|
||||
|
||||
|
||||
# For previews in the admin panel, which show the HTML without message parts.
|
||||
LOGO_DATA_URL = "data:image/png;base64," + base64.b64encode(_LOGO_FILE.read_bytes()).decode()
|
||||
|
||||
|
||||
class MailerError(Exception):
|
||||
"""System mail is not configured, or the SMTP server refused the message."""
|
||||
|
||||
@@ -76,19 +88,49 @@ def mailer_config(row: AppSettings) -> MailerConfig | None:
|
||||
)
|
||||
|
||||
|
||||
async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
|
||||
async def send_mail(
|
||||
cfg: MailerConfig,
|
||||
to: str,
|
||||
subject: str,
|
||||
text: str,
|
||||
html: str,
|
||||
*,
|
||||
sender: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
inline: Sequence[InlinePart] = (),
|
||||
) -> None:
|
||||
"""Send one system message. ``sender`` replaces the configured From address
|
||||
(the SMTP account must be allowed to send as it); ``inline`` are images the
|
||||
HTML names by ``cid:``."""
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((SENDER_NAME, cfg.sender))
|
||||
msg["From"] = formataddr((SENDER_NAME, sender or cfg.sender))
|
||||
msg["To"] = to
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
msg["Subject"] = subject
|
||||
# RFC 5322 requires it, and mail without one scores as spam (Gmail).
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid(domain=cfg.sender.rsplit("@", 1)[-1])
|
||||
msg["Auto-Submitted"] = "auto-generated" # RFC 3834: do not auto-reply to this
|
||||
msg.set_content(text)
|
||||
msg.add_alternative(html, subtype="html")
|
||||
if f"cid:{_LOGO_CID}" in html:
|
||||
if f"cid:{LOGO_CID}" in html:
|
||||
html_part = msg.get_body(("html",))
|
||||
if isinstance(html_part, EmailMessage):
|
||||
html_part.add_related(_logo(), maintype="image", subtype="png", cid=f"<{_LOGO_CID}>")
|
||||
html_part.add_related(_logo(), maintype="image", subtype="png", cid=f"<{LOGO_CID}>")
|
||||
if inline:
|
||||
html_part = msg.get_body(("html",))
|
||||
assert isinstance(html_part, EmailMessage)
|
||||
for part in inline:
|
||||
maintype, _, subtype = part.mime_type.partition("/")
|
||||
html_part.add_related(
|
||||
part.data,
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
cid=f"<{part.cid}>",
|
||||
filename=part.filename,
|
||||
disposition="inline",
|
||||
)
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
@@ -99,6 +141,7 @@ async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: s
|
||||
use_tls=cfg.security == "ssl",
|
||||
start_tls=cfg.security == "starttls",
|
||||
timeout=_TIMEOUT,
|
||||
local_hostname=smtp_helo_name(),
|
||||
)
|
||||
except (aiosmtplib.SMTPException, OSError) as exc:
|
||||
raise MailerError(str(exc) or type(exc).__name__) from exc
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.events import get_event_bus
|
||||
from app.hosted.admin import MailServerError
|
||||
from app.identity import app_passwords, trusted_devices
|
||||
from app.identity import app_passwords, invitations, trusted_devices
|
||||
from app.identity import totp as totp_mod
|
||||
from app.identity.cookies import (
|
||||
clear_auth_cookies,
|
||||
@@ -40,6 +40,8 @@ from app.identity.schemas import (
|
||||
AppPasswordCreatedOut,
|
||||
AppPasswordIn,
|
||||
AppPasswordOut,
|
||||
InvitationCheckIn,
|
||||
InvitationCheckOut,
|
||||
LoginRequest,
|
||||
MessageOut,
|
||||
PasswordResetCheckIn,
|
||||
@@ -116,12 +118,31 @@ def _user_out(user: User) -> UserOut:
|
||||
async def registration(db: DbSession) -> RegistrationStatusOut:
|
||||
"""Whether signup is open. Unauthenticated: the signup page needs it to render."""
|
||||
status = await registration_status(db)
|
||||
return RegistrationStatusOut(open=status.open, bootstrap=status.bootstrap)
|
||||
return RegistrationStatusOut(
|
||||
open=status.open, bootstrap=status.bootstrap, domain=get_settings().app_domain
|
||||
)
|
||||
|
||||
|
||||
_REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403}
|
||||
|
||||
|
||||
@router.post("/invitation/check", response_model=InvitationCheckOut)
|
||||
async def invitation_check(body: InvitationCheckIn, db: DbSession) -> InvitationCheckOut:
|
||||
"""Whether an invitation link still works, and who it is from — for the
|
||||
sign-up page. Does not use it up."""
|
||||
if not (await app_settings(db)).invitations_enabled:
|
||||
return InvitationCheckOut(valid=False)
|
||||
invitation = await invitations.find_valid(db, body.token)
|
||||
if invitation is None:
|
||||
return InvitationCheckOut(valid=False)
|
||||
inviter = await db.get(User, invitation.inviter_id)
|
||||
return InvitationCheckOut(
|
||||
valid=True,
|
||||
invitee_name=invitation.invitee_name,
|
||||
inviter_name=await invitations.inviter_name(db, inviter) if inviter else "",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut)
|
||||
async def register(
|
||||
body: RegisterRequest, request: Request, response: Response, db: DbSession
|
||||
@@ -130,15 +151,28 @@ async def register(
|
||||
await check_new_password(body.password, resolve_handle(body.localpart))
|
||||
except PasswordRejected as exc:
|
||||
raise_api_error(400, exc.code, exc.message)
|
||||
invitation_id: uuid.UUID | None = None
|
||||
if body.invite:
|
||||
# Used up in this same transaction: if the sign-up fails (the address
|
||||
# is taken, …) the rollback hands the invitation back.
|
||||
if (await app_settings(db)).invitations_enabled:
|
||||
invitation_id = await invitations.claim(db, body.invite)
|
||||
if invitation_id is None:
|
||||
raise_api_error(
|
||||
400, "invalid_invitation", "this invitation has already been used or has expired"
|
||||
)
|
||||
try:
|
||||
user = await register_user(
|
||||
db,
|
||||
localpart=body.localpart,
|
||||
password=body.password,
|
||||
recovery_email=body.recovery_email,
|
||||
invited=invitation_id is not None,
|
||||
)
|
||||
except RegistrationError as exc:
|
||||
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
|
||||
if invitation_id is not None:
|
||||
await invitations.record_user(db, invitation_id, user.id)
|
||||
await db.commit()
|
||||
# The user's own mailbox (App. D §D.4): it exists on the mail server as soon
|
||||
# as the user does (the directory reads the users table); this adds it to
|
||||
|
||||
@@ -12,6 +12,9 @@ class RegisterRequest(BaseModel):
|
||||
localpart: str = Field(min_length=3, max_length=30)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
recovery_email: str | None = None
|
||||
# A friend's invitation (app/identity/invitations.py): signs up even while
|
||||
# public registration is closed, and is used up by it.
|
||||
invite: str | None = Field(default=None, max_length=200)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -98,23 +101,39 @@ class UserOut(BaseModel):
|
||||
mail_host: str | None = None
|
||||
|
||||
|
||||
class InvitationCheckIn(BaseModel):
|
||||
token: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class InvitationCheckOut(BaseModel):
|
||||
valid: bool
|
||||
invitee_name: str = ""
|
||||
inviter_name: str = ""
|
||||
|
||||
|
||||
class RegistrationStatusOut(BaseModel):
|
||||
"""Public, unauthenticated: what the signup page should show."""
|
||||
|
||||
open: bool
|
||||
# True when the instance has no users yet, so signup creates the administrator.
|
||||
bootstrap: bool
|
||||
# The domain every handle lives under (localpart@domain), shown beside the
|
||||
# handle field.
|
||||
domain: str
|
||||
|
||||
|
||||
class AdminSettingsOut(BaseModel):
|
||||
registration_open: bool
|
||||
default_quota_bytes: int
|
||||
# Users may send friends sign-up invitations (app/identity/invitations.py).
|
||||
invitations_enabled: bool
|
||||
|
||||
|
||||
class AdminSettingsIn(BaseModel):
|
||||
"""Merge semantics: omitted fields are left as they are."""
|
||||
|
||||
registration_open: bool | None = None
|
||||
invitations_enabled: bool | None = None
|
||||
# Applies to users created after the change; existing mailboxes keep theirs.
|
||||
default_quota_bytes: int | None = Field(default=None, gt=0)
|
||||
|
||||
@@ -145,6 +164,36 @@ class AdminMailIn(BaseModel):
|
||||
public_url: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class AdminInviteTemplateOut(BaseModel):
|
||||
# Empty strings: the built-in subject / template / sender is used.
|
||||
subject: str
|
||||
html: str
|
||||
from_address: str
|
||||
default_subject: str
|
||||
default_html: str
|
||||
default_from: str
|
||||
placeholders: list[str]
|
||||
|
||||
|
||||
class AdminInviteTemplateIn(BaseModel):
|
||||
"""Merge semantics: omitted fields are left as they are; an empty string
|
||||
goes back to the built-in one."""
|
||||
|
||||
subject: str | None = Field(default=None, max_length=300)
|
||||
html: str | None = None
|
||||
from_address: str | None = Field(default=None, max_length=320)
|
||||
|
||||
|
||||
class AdminInvitePreviewIn(BaseModel):
|
||||
subject: str = Field(default="", max_length=300)
|
||||
html: str = ""
|
||||
|
||||
|
||||
class AdminInvitePreviewOut(BaseModel):
|
||||
subject: str
|
||||
html: str
|
||||
|
||||
|
||||
class AdminMailTestIn(BaseModel):
|
||||
to: str = Field(min_length=3, max_length=320)
|
||||
|
||||
|
||||
@@ -97,7 +97,12 @@ async def registration_status(db: AsyncSession) -> RegistrationStatus:
|
||||
|
||||
|
||||
async def register_user(
|
||||
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
|
||||
db: AsyncSession,
|
||||
*,
|
||||
localpart: str,
|
||||
password: str,
|
||||
recovery_email: str | None,
|
||||
invited: bool = False,
|
||||
) -> User:
|
||||
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness.
|
||||
|
||||
@@ -114,7 +119,7 @@ async def register_user(
|
||||
|
||||
settings = await app_settings(db, for_update=True)
|
||||
bootstrap = not await _has_users(db)
|
||||
if not bootstrap and not settings.registration_open:
|
||||
if not bootstrap and not settings.registration_open and not invited:
|
||||
raise RegistrationError("registration_closed", "registration is closed on this server")
|
||||
|
||||
user = User(
|
||||
|
||||
@@ -87,6 +87,13 @@ class AppSettings(Base):
|
||||
# take it (base64url, uncompressed P-256 point).
|
||||
vapid_public: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
vapid_private_ct: Mapped[bytes | None] = mapped_column(BYTEA)
|
||||
# Users may invite friends (app/identity/invitations.py); the invitation
|
||||
# email's From address (empty: postmaster@APP_DOMAIN), subject and HTML
|
||||
# template (empty: the built-in ones).
|
||||
invitations_enabled: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
||||
invite_from: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
invite_subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
invite_template: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
updated_at: Mapped[dt.datetime] = _now()
|
||||
|
||||
|
||||
@@ -376,6 +383,27 @@ class UserAppPassword(Base):
|
||||
server_id: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class Invitation(Base):
|
||||
"""A single-use link that lets one person sign up, sent by a user."""
|
||||
|
||||
__tablename__ = "invitations"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
inviter_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# sha256 of the token in the link; the token itself is only in the email.
|
||||
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
invitee_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
invitee_email: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[dt.datetime] = _now()
|
||||
expires_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||
used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
used_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
"""A browser or installed app to tell about new mail (Web Push)."""
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -20,7 +20,7 @@ import aiosmtplib
|
||||
from aioimaplib import aioimaplib
|
||||
|
||||
from app.concurrency import loop_semaphore
|
||||
from app.config import get_settings
|
||||
from app.config import get_settings, smtp_helo_name
|
||||
from app.providers.base import (
|
||||
FlagChange,
|
||||
MailboxDelta,
|
||||
@@ -579,6 +579,8 @@ class ImapSmtpProvider(MailProvider):
|
||||
start_tls=start_tls,
|
||||
tls_context=cfg.tls_context,
|
||||
timeout=_CONNECT_TIMEOUT,
|
||||
# Our own name, not the container's random host name.
|
||||
local_hostname=smtp_helo_name(),
|
||||
)
|
||||
except aiosmtplib.SMTPException as exc:
|
||||
raise _smtp_error(exc) from exc
|
||||
|
||||
Reference in New Issue
Block a user