Private
Public Access
Style outgoing emails instead of plain text (#68)
Every email went through one shared plain-text-only path. Redesigned send_email/send_test_email around structured paragraphs + an optional CTA button instead of one pre-formatted string, and render both a proper styled HTML card (table-based, inline styles -- email clients strip <style> blocks and don't support CSS variables) and a clean plain-text fallback from the same input, sent as multipart/alternative. The HTML is themed per recipient: an email to an existing user renders in their own selected theme (dark/light/midnight/sunset, or their saved custom palette), resolved server-side from User.theme/ active_custom_theme_id. Site invites have no account yet to read a theme from, so they use the default DarkSingularity palette. All five existing email triggers (site invite, room-added, password reset, #66's DM notification, admin test email) updated to the new call shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -376,7 +376,7 @@ async def test_smtp_settings_endpoint(
|
|||||||
):
|
):
|
||||||
require_site_admin(current_user)
|
require_site_admin(current_user)
|
||||||
try:
|
try:
|
||||||
await send_test_email(db, current_user.email)
|
await send_test_email(db, current_user.email, theme_user=current_user)
|
||||||
except SmtpNotConfiguredError:
|
except SmtpNotConfiguredError:
|
||||||
raise HTTPException(status_code=400, detail="SMTP is not configured yet")
|
raise HTTPException(status_code=400, detail="SMTP is not configured yet")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
@@ -5,7 +6,7 @@ import aiosmtplib
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.crypto import decrypt
|
from app.crypto import decrypt
|
||||||
from app.models import SmtpSettings
|
from app.models import CustomTheme, SmtpSettings, User
|
||||||
from app.services.smtp_settings_service import get_smtp_settings
|
from app.services.smtp_settings_service import get_smtp_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -15,14 +16,156 @@ class SmtpNotConfiguredError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str) -> None:
|
# #68: the 6 tokens actually used by the email template below, out of the
|
||||||
|
# full ~12-token palette themes.css defines per preset -- an email has no
|
||||||
|
# equivalent of --ds-surface-2/--ds-void-2/--ds-highlight/--ds-danger, it's
|
||||||
|
# one card on one background with one accent. Kept in sync by hand with
|
||||||
|
# frontend/src/styles/tokens.css (the default) and themes.css (the other
|
||||||
|
# three presets) -- there's no way to share the source of truth across the
|
||||||
|
# Python/CSS boundary, so if either changes the other needs updating too.
|
||||||
|
DEFAULT_PALETTE = {
|
||||||
|
"void": "#07080f",
|
||||||
|
"surface": "#101030",
|
||||||
|
"border": "#242478",
|
||||||
|
"text": "#fce4fc",
|
||||||
|
"muted": "#c0ccd8",
|
||||||
|
"accent": "#60d8fc",
|
||||||
|
}
|
||||||
|
_PRESET_PALETTES: dict[str, dict[str, str]] = {
|
||||||
|
"dark": DEFAULT_PALETTE,
|
||||||
|
"light": {
|
||||||
|
"void": "#f5f3fb",
|
||||||
|
"surface": "#ffffff",
|
||||||
|
"border": "#d8d2ee",
|
||||||
|
"text": "#1a1030",
|
||||||
|
"muted": "#675f80",
|
||||||
|
"accent": "#0891b2",
|
||||||
|
},
|
||||||
|
"midnight": {
|
||||||
|
"void": "#000000",
|
||||||
|
"surface": "#0a0a14",
|
||||||
|
"border": "#262640",
|
||||||
|
"text": "#ffffff",
|
||||||
|
"muted": "#a8b0c0",
|
||||||
|
"accent": "#00f0ff",
|
||||||
|
},
|
||||||
|
"sunset": {
|
||||||
|
"void": "#120a07",
|
||||||
|
"surface": "#241408",
|
||||||
|
"border": "#4a2c14",
|
||||||
|
"text": "#fce8d8",
|
||||||
|
"muted": "#c8b0a0",
|
||||||
|
"accent": "#fca050",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_palette(db: AsyncSession, theme_user: User | None) -> dict[str, str]:
|
||||||
|
"""#68: an email addressed to an existing user is styled with *their*
|
||||||
|
selected theme (mirroring the app itself), not a fixed look -- but
|
||||||
|
there's no such thing as "their theme" for someone who doesn't have an
|
||||||
|
account yet (site invites), so theme_user is None there and this falls
|
||||||
|
back to the default DarkSingularity palette, same as a logged-out page.
|
||||||
|
"""
|
||||||
|
if theme_user is None or theme_user.theme is None:
|
||||||
|
return DEFAULT_PALETTE
|
||||||
|
if theme_user.theme == "custom":
|
||||||
|
if theme_user.active_custom_theme_id is not None:
|
||||||
|
# A fresh PK fetch, not `theme_user.active_custom_theme` --
|
||||||
|
# that relationship is essentially never eager-loaded by
|
||||||
|
# whatever query got this User row in the first place, and
|
||||||
|
# touching it lazily here would raise MissingGreenlet in
|
||||||
|
# async SQLAlchemy.
|
||||||
|
custom = await db.get(CustomTheme, theme_user.active_custom_theme_id)
|
||||||
|
if custom is not None:
|
||||||
|
colors = custom.colors
|
||||||
|
return {key: colors[key] for key in DEFAULT_PALETTE}
|
||||||
|
return DEFAULT_PALETTE
|
||||||
|
return _PRESET_PALETTES.get(theme_user.theme, DEFAULT_PALETTE)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_text(paragraphs: list[str], cta_label: str | None, cta_url: str | None) -> str:
|
||||||
|
body = "\n\n".join(paragraphs)
|
||||||
|
if cta_label and cta_url:
|
||||||
|
body += f"\n\n{cta_label}: {cta_url}"
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _render_html(
|
||||||
|
palette: dict[str, str],
|
||||||
|
subject: str,
|
||||||
|
paragraphs: list[str],
|
||||||
|
cta_label: str | None,
|
||||||
|
cta_url: str | None,
|
||||||
|
) -> str:
|
||||||
|
# Table-based layout with everything inlined -- not the app's own CSS
|
||||||
|
# custom properties (email clients strip <style> blocks and don't
|
||||||
|
# support :root variables), just their resolved hex values baked in
|
||||||
|
# per send. Deliberately plain: one card, one accent color, no imagery
|
||||||
|
# that could get blocked by a client's "show images" gate and leave
|
||||||
|
# the email looking broken instead of just plain.
|
||||||
|
paragraphs_html = "".join(
|
||||||
|
f'<p style="margin:0 0 16px;color:{palette["muted"]};font-size:15px;'
|
||||||
|
f'line-height:1.6;">{html.escape(p)}</p>'
|
||||||
|
for p in paragraphs
|
||||||
|
)
|
||||||
|
cta_html = ""
|
||||||
|
if cta_label and cta_url:
|
||||||
|
cta_html = f"""
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 4px;">
|
||||||
|
<tr>
|
||||||
|
<td style="border-radius:8px;background:{palette["accent"]};">
|
||||||
|
<a href="{html.escape(cta_url)}" style="display:inline-block;padding:12px 22px;
|
||||||
|
font-size:15px;font-weight:700;color:{palette["void"]};text-decoration:none;
|
||||||
|
border-radius:8px;">{html.escape(cta_label)}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
"""
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:{palette["void"]};">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||||
|
style="background:{palette["void"]};padding:32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="480" cellpadding="0" cellspacing="0"
|
||||||
|
style="max-width:480px;width:100%;background:{palette["surface"]};
|
||||||
|
border:1px solid {palette["border"]};border-radius:12px;padding:32px;
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-size:13px;font-weight:800;letter-spacing:0.06em;
|
||||||
|
text-transform:uppercase;color:{palette["accent"]};margin:0 0 20px;">DS Chat</div>
|
||||||
|
<h1 style="margin:0 0 16px;font-size:20px;font-weight:800;
|
||||||
|
color:{palette["text"]};">{html.escape(subject)}</h1>
|
||||||
|
{paragraphs_html}
|
||||||
|
{cta_html}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _deliver(
|
||||||
|
cfg: SmtpSettings, to_address: str, subject: str, html_body: str, text_body: str
|
||||||
|
) -> None:
|
||||||
"""Raises on failure -- internal helper only. Callers decide whether to
|
"""Raises on failure -- internal helper only. Callers decide whether to
|
||||||
swallow (send_email) or surface (send_test_email) the error."""
|
swallow (send_email) or surface (send_test_email) the error."""
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
message["From"] = cfg.from_address
|
message["From"] = cfg.from_address
|
||||||
message["To"] = to_address
|
message["To"] = to_address
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
message.set_content(body)
|
# Plain-text part first, HTML as the alternative -- standard
|
||||||
|
# multipart/alternative ordering (least to most preferred), so a
|
||||||
|
# client with no HTML support (or a spam filter) still gets a normal
|
||||||
|
# readable email instead of raw markup.
|
||||||
|
message.set_content(text_body)
|
||||||
|
message.add_alternative(html_body, subtype="html")
|
||||||
|
|
||||||
password = decrypt(cfg.password_encrypted) if cfg.password_encrypted else None
|
password = decrypt(cfg.password_encrypted) if cfg.password_encrypted else None
|
||||||
|
|
||||||
@@ -55,29 +198,51 @@ async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def send_email(db: AsyncSession, to_address: str, subject: str, body: str) -> None:
|
async def send_email(
|
||||||
|
db: AsyncSession,
|
||||||
|
to_address: str,
|
||||||
|
subject: str,
|
||||||
|
paragraphs: list[str],
|
||||||
|
*,
|
||||||
|
cta_label: str | None = None,
|
||||||
|
cta_url: str | None = None,
|
||||||
|
theme_user: User | None = None,
|
||||||
|
) -> None:
|
||||||
"""Best-effort -- used by invite/notification flows. Never raises: an
|
"""Best-effort -- used by invite/notification flows. Never raises: an
|
||||||
SMTP outage or missing configuration must never block an action (an
|
SMTP outage or missing configuration must never block an action (an
|
||||||
invite, a room membership) that already succeeded in the database."""
|
invite, a room membership) that already succeeded in the database.
|
||||||
|
|
||||||
|
`paragraphs` replaces the old flat `body: str` (#68) -- each entry
|
||||||
|
renders as its own paragraph in both the HTML and plain-text parts,
|
||||||
|
which a single pre-formatted string can't cleanly become HTML from
|
||||||
|
without re-parsing it. `theme_user`, when given, styles the email with
|
||||||
|
that user's own selected theme (default palette if they haven't picked
|
||||||
|
one, or don't have an account at all -- see _resolve_palette).
|
||||||
|
"""
|
||||||
cfg = await get_smtp_settings(db)
|
cfg = await get_smtp_settings(db)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
logger.debug("SMTP not configured; skipping email to %s", to_address)
|
logger.debug("SMTP not configured; skipping email to %s", to_address)
|
||||||
return
|
return
|
||||||
|
palette = await _resolve_palette(db, theme_user)
|
||||||
|
html_body = _render_html(palette, subject, paragraphs, cta_label, cta_url)
|
||||||
|
text_body = _render_text(paragraphs, cta_label, cta_url)
|
||||||
try:
|
try:
|
||||||
await _deliver(cfg, to_address, subject, body)
|
await _deliver(cfg, to_address, subject, html_body, text_body)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to send email to %s", to_address, exc_info=True)
|
logger.warning("Failed to send email to %s", to_address, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def send_test_email(db: AsyncSession, to_address: str) -> None:
|
async def send_test_email(db: AsyncSession, to_address: str, theme_user: User | None = None) -> None:
|
||||||
"""Used only by the admin 'send test email' button -- raises so the
|
"""Used only by the admin 'send test email' button -- raises so the
|
||||||
admin UI can show why it failed instead of a silent no-op."""
|
admin UI can show why it failed instead of a silent no-op. theme_user
|
||||||
|
is the admin themselves (see routers/admin.py) -- the preview shows
|
||||||
|
them their own emails' real look, not a generic default."""
|
||||||
cfg = await get_smtp_settings(db)
|
cfg = await get_smtp_settings(db)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
raise SmtpNotConfiguredError()
|
raise SmtpNotConfiguredError()
|
||||||
await _deliver(
|
subject = "DS Chat test email"
|
||||||
cfg,
|
paragraphs = ["This is a test email from DS Chat to confirm your SMTP settings are working."]
|
||||||
to_address,
|
palette = await _resolve_palette(db, theme_user)
|
||||||
"DS Chat test email",
|
html_body = _render_html(palette, subject, paragraphs, None, None)
|
||||||
"This is a test email from DS Chat to confirm your SMTP settings are working.",
|
text_body = _render_text(paragraphs, None, None)
|
||||||
)
|
await _deliver(cfg, to_address, subject, html_body, text_body)
|
||||||
|
|||||||
@@ -215,7 +215,10 @@ async def _maybe_email_dm_notification(
|
|||||||
db,
|
db,
|
||||||
recipient.email,
|
recipient.email,
|
||||||
f"New message from {sender.username}",
|
f"New message from {sender.username}",
|
||||||
f"{body_line}\n\nView it here:\n{link}",
|
[body_line],
|
||||||
|
cta_label="Open conversation",
|
||||||
|
cta_url=link,
|
||||||
|
theme_user=recipient,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,13 @@ async def request_password_reset(db: AsyncSession, email: str, base_url: str) ->
|
|||||||
db,
|
db,
|
||||||
email,
|
email,
|
||||||
"Reset your DS Chat password",
|
"Reset your DS Chat password",
|
||||||
f"Someone requested a password reset for this account.\n\n"
|
[
|
||||||
f"Reset it here:\n{reset_link}\n\n"
|
"Someone requested a password reset for this account.",
|
||||||
f"This link expires in 15 minutes. If you didn't request this, "
|
"This link expires in 15 minutes. If you didn't request this, you can ignore this email.",
|
||||||
f"you can ignore this email.",
|
],
|
||||||
|
cta_label="Reset password",
|
||||||
|
cta_url=reset_link,
|
||||||
|
theme_user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -316,8 +316,10 @@ async def add_member(
|
|||||||
db,
|
db,
|
||||||
target.email,
|
target.email,
|
||||||
f"You've been added to #{room.name}",
|
f"You've been added to #{room.name}",
|
||||||
f"You've been added to the #{room.name} room on DS Chat.\n\n"
|
[f"You've been added to the #{room.name} room on DS Chat."],
|
||||||
f"Open the app: {base_url.rstrip('/')}",
|
cta_label="Open DS Chat",
|
||||||
|
cta_url=base_url.rstrip("/"),
|
||||||
|
theme_user=target,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@@ -40,13 +40,19 @@ async def create_site_invite(
|
|||||||
await db.refresh(invite)
|
await db.refresh(invite)
|
||||||
|
|
||||||
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
|
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
|
||||||
|
# No theme_user -- the invitee doesn't have an account yet, so there's
|
||||||
|
# no theme of theirs to use (#68). Default palette, same as any
|
||||||
|
# logged-out page.
|
||||||
await send_email(
|
await send_email(
|
||||||
db,
|
db,
|
||||||
email,
|
email,
|
||||||
"You're invited to join DS Chat",
|
"You're invited to join DS Chat",
|
||||||
f"You've been invited to join DS Chat by {actor.username}.\n\n"
|
[
|
||||||
f"Set up your account here:\n{signup_link}\n\n"
|
f"You've been invited to join DS Chat by {actor.username}.",
|
||||||
f"This link expires in 7 days.",
|
"This link expires in 7 days.",
|
||||||
|
],
|
||||||
|
cta_label="Set up your account",
|
||||||
|
cta_url=signup_link,
|
||||||
)
|
)
|
||||||
return invite
|
return invite
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ def _recv(ws) -> dict:
|
|||||||
def _fake_send_email(monkeypatch):
|
def _fake_send_email(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def fake(db, to, subject, body):
|
async def fake(db, to, subject, paragraphs, **kwargs):
|
||||||
calls.append({"to": to, "subject": subject, "body": body})
|
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
|
||||||
|
|
||||||
monkeypatch.setattr("app.services.room_service.send_email", fake)
|
monkeypatch.setattr("app.services.room_service.send_email", fake)
|
||||||
return calls
|
return calls
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ def test_dm_message_emails_globally_offline_recipient(ws_client_factory, monkeyp
|
|||||||
email = calls[0]["message"]
|
email = calls[0]["message"]
|
||||||
assert email["To"] == bob["email"]
|
assert email["To"] == bob["email"]
|
||||||
assert f"New message from {alice['username']}" in email["Subject"]
|
assert f"New message from {alice['username']}" in email["Subject"]
|
||||||
body = email.get_content()
|
# #68: the email is now multipart/alternative (HTML + plain-text
|
||||||
|
# fallback) -- get_body(preferencelist=...) reaches a specific part,
|
||||||
|
# unlike get_content() which has no handler for the multipart itself.
|
||||||
|
body = email.get_body(preferencelist=("plain",)).get_content()
|
||||||
assert f"{alice['username']}: hey, you there?" in body
|
assert f"{alice['username']}: hey, you there?" in body
|
||||||
assert f"/rooms/{dm['id']}" in body
|
assert f"/rooms/{dm['id']}" in body
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,17 @@ from tests.conftest import register_and_login
|
|||||||
def _fake_send_email(monkeypatch):
|
def _fake_send_email(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def fake(db, to, subject, body):
|
async def fake(db, to, subject, paragraphs, *, cta_label=None, cta_url=None, theme_user=None):
|
||||||
calls.append({"to": to, "subject": subject, "body": body})
|
calls.append(
|
||||||
|
{
|
||||||
|
"to": to,
|
||||||
|
"subject": subject,
|
||||||
|
"paragraphs": paragraphs,
|
||||||
|
"cta_label": cta_label,
|
||||||
|
"cta_url": cta_url,
|
||||||
|
"theme_user": theme_user,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("app.services.password_service.send_email", fake)
|
monkeypatch.setattr("app.services.password_service.send_email", fake)
|
||||||
return calls
|
return calls
|
||||||
@@ -91,7 +100,7 @@ async def test_reset_password_flow_end_to_end(client, db_session, monkeypatch):
|
|||||||
await client.post("/api/auth/logout")
|
await client.post("/api/auth/logout")
|
||||||
|
|
||||||
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
||||||
token = _extract_token(calls[0]["body"])
|
token = _extract_token(calls[0]["cta_url"])
|
||||||
|
|
||||||
validate = await client.get(f"/api/auth/reset-password/validate?token={token}")
|
validate = await client.get(f"/api/auth/reset-password/validate?token={token}")
|
||||||
assert validate.status_code == 204
|
assert validate.status_code == 204
|
||||||
@@ -133,7 +142,7 @@ async def test_reset_password_expired_token_rejected(client, db_session, monkeyp
|
|||||||
await register_and_login(client, db_session, username="alice")
|
await register_and_login(client, db_session, username="alice")
|
||||||
await client.post("/api/auth/logout")
|
await client.post("/api/auth/logout")
|
||||||
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
||||||
token = _extract_token(calls[0]["body"])
|
token = _extract_token(calls[0]["cta_url"])
|
||||||
|
|
||||||
reset = (await db_session.execute(select(PasswordReset))).scalar_one()
|
reset = (await db_session.execute(select(PasswordReset))).scalar_one()
|
||||||
reset.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
reset.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||||
@@ -150,7 +159,7 @@ async def test_reset_password_used_token_cannot_be_reused(client, db_session, mo
|
|||||||
await register_and_login(client, db_session, username="alice")
|
await register_and_login(client, db_session, username="alice")
|
||||||
await client.post("/api/auth/logout")
|
await client.post("/api/auth/logout")
|
||||||
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
||||||
token = _extract_token(calls[0]["body"])
|
token = _extract_token(calls[0]["cta_url"])
|
||||||
|
|
||||||
first = await client.post(
|
first = await client.post(
|
||||||
"/api/auth/reset-password", json={"token": token, "new_password": "firstpass123"}
|
"/api/auth/reset-password", json={"token": token, "new_password": "firstpass123"}
|
||||||
|
|||||||
@@ -450,8 +450,8 @@ async def test_change_member_role_owner_only(client, db_session):
|
|||||||
def _fake_send_email(monkeypatch):
|
def _fake_send_email(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def fake(db, to, subject, body):
|
async def fake(db, to, subject, paragraphs, **kwargs):
|
||||||
calls.append({"to": to, "subject": subject, "body": body})
|
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
|
||||||
|
|
||||||
monkeypatch.setattr("app.services.room_service.send_email", fake)
|
monkeypatch.setattr("app.services.room_service.send_email", fake)
|
||||||
return calls
|
return calls
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ def _extract_token(body: str) -> str:
|
|||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_text(message) -> str:
|
||||||
|
# #68: the email is now multipart/alternative (HTML + plain-text
|
||||||
|
# fallback) -- .get_content() has no handler for a multipart message
|
||||||
|
# itself, get_body(preferencelist=...) is the standard way to reach a
|
||||||
|
# specific alternative part.
|
||||||
|
return message.get_body(preferencelist=("plain",)).get_content()
|
||||||
|
|
||||||
|
|
||||||
async def test_create_site_invite_requires_admin(client, db_session):
|
async def test_create_site_invite_requires_admin(client, db_session):
|
||||||
await register_and_login(client, db_session, username="alice")
|
await register_and_login(client, db_session, username="alice")
|
||||||
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"})
|
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"})
|
||||||
@@ -62,7 +70,7 @@ async def test_signup_flow_end_to_end(client, db_session, monkeypatch):
|
|||||||
assert invite["status"] == "pending"
|
assert invite["status"] == "pending"
|
||||||
|
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
token = _extract_token(calls[0]["message"].get_content())
|
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||||
|
|
||||||
validate = await client.get(f"/api/signup/validate?token={token}")
|
validate = await client.get(f"/api/signup/validate?token={token}")
|
||||||
assert validate.status_code == 200
|
assert validate.status_code == 200
|
||||||
@@ -87,7 +95,7 @@ async def test_signup_rejects_mismatched_password_confirmation(client, db_sessio
|
|||||||
await _configure_smtp(client)
|
await _configure_smtp(client)
|
||||||
|
|
||||||
await client.post("/api/admin/invites", json={"email": "typo@example.com"})
|
await client.post("/api/admin/invites", json={"email": "typo@example.com"})
|
||||||
token = _extract_token(calls[0]["message"].get_content())
|
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||||
|
|
||||||
complete = await client.post(
|
complete = await client.post(
|
||||||
"/api/signup",
|
"/api/signup",
|
||||||
@@ -127,7 +135,7 @@ async def test_expired_token_rejected(client, db_session, monkeypatch):
|
|||||||
|
|
||||||
resp = await client.post("/api/admin/invites", json={"email": "late@example.com"})
|
resp = await client.post("/api/admin/invites", json={"email": "late@example.com"})
|
||||||
invite_id = resp.json()["id"]
|
invite_id = resp.json()["id"]
|
||||||
token = _extract_token(calls[0]["message"].get_content())
|
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||||
|
|
||||||
db_invite = await db_session.get(SiteInvite, uuid.UUID(invite_id))
|
db_invite = await db_session.get(SiteInvite, uuid.UUID(invite_id))
|
||||||
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
@@ -147,7 +155,7 @@ async def test_used_token_cannot_be_reused(client, db_session, monkeypatch):
|
|||||||
await _configure_smtp(client)
|
await _configure_smtp(client)
|
||||||
|
|
||||||
await client.post("/api/admin/invites", json={"email": "once@example.com"})
|
await client.post("/api/admin/invites", json={"email": "once@example.com"})
|
||||||
token = _extract_token(calls[0]["message"].get_content())
|
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||||
|
|
||||||
first = await client.post(
|
first = await client.post(
|
||||||
"/api/signup",
|
"/api/signup",
|
||||||
@@ -170,7 +178,7 @@ async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatc
|
|||||||
|
|
||||||
resp = await client.post("/api/admin/invites", json={"email": "revoked@example.com"})
|
resp = await client.post("/api/admin/invites", json={"email": "revoked@example.com"})
|
||||||
invite_id = resp.json()["id"]
|
invite_id = resp.json()["id"]
|
||||||
token = _extract_token(calls[0]["message"].get_content())
|
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||||
|
|
||||||
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
||||||
assert revoke.status_code == 200
|
assert revoke.status_code == 200
|
||||||
@@ -226,7 +234,7 @@ async def test_list_site_invites_excludes_accepted_invite(client, db_session, mo
|
|||||||
await _configure_smtp(client)
|
await _configure_smtp(client)
|
||||||
|
|
||||||
await client.post("/api/admin/invites", json={"email": "accepted-from-list@example.com"})
|
await client.post("/api/admin/invites", json={"email": "accepted-from-list@example.com"})
|
||||||
token = _extract_token(calls[0]["message"].get_content())
|
token = _extract_token(_plain_text(calls[0]["message"]))
|
||||||
|
|
||||||
complete = await client.post(
|
complete = await client.post(
|
||||||
"/api/signup",
|
"/api/signup",
|
||||||
|
|||||||
Reference in New Issue
Block a user