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:
+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 --------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user