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:
2026-08-28 17:39:08 -06:00
co-authored by Claude Sonnet 5
parent 7b44ce325c
commit 1a05cf5515
11 changed files with 240 additions and 41 deletions
+1 -1
View File
@@ -376,7 +376,7 @@ async def test_smtp_settings_endpoint(
):
require_site_admin(current_user)
try:
await send_test_email(db, current_user.email)
await send_test_email(db, current_user.email, theme_user=current_user)
except SmtpNotConfiguredError:
raise HTTPException(status_code=400, detail="SMTP is not configured yet")
except Exception as exc:
+179 -14
View File
@@ -1,3 +1,4 @@
import html
import logging
from email.message import EmailMessage
@@ -5,7 +6,7 @@ import aiosmtplib
from sqlalchemy.ext.asyncio import AsyncSession
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
logger = logging.getLogger(__name__)
@@ -15,14 +16,156 @@ class SmtpNotConfiguredError(Exception):
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
swallow (send_email) or surface (send_test_email) the error."""
message = EmailMessage()
message["From"] = cfg.from_address
message["To"] = to_address
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
@@ -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
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)
if cfg is None:
logger.debug("SMTP not configured; skipping email to %s", to_address)
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:
await _deliver(cfg, to_address, subject, body)
await _deliver(cfg, to_address, subject, html_body, text_body)
except Exception:
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
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)
if cfg is None:
raise SmtpNotConfiguredError()
await _deliver(
cfg,
to_address,
"DS Chat test email",
"This is a test email from DS Chat to confirm your SMTP settings are working.",
)
subject = "DS Chat test email"
paragraphs = ["This is a test email from DS Chat to confirm your SMTP settings are working."]
palette = await _resolve_palette(db, theme_user)
html_body = _render_html(palette, subject, paragraphs, None, None)
text_body = _render_text(paragraphs, None, None)
await _deliver(cfg, to_address, subject, html_body, text_body)
+4 -1
View File
@@ -215,7 +215,10 @@ async def _maybe_email_dm_notification(
db,
recipient.email,
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,
)
+7 -4
View File
@@ -46,10 +46,13 @@ async def request_password_reset(db: AsyncSession, email: str, base_url: str) ->
db,
email,
"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"
f"This link expires in 15 minutes. If you didn't request this, "
f"you can ignore this email.",
[
"Someone requested a password reset for this account.",
"This link expires in 15 minutes. If you didn't request this, you can ignore this email.",
],
cta_label="Reset password",
cta_url=reset_link,
theme_user=user,
)
+4 -2
View File
@@ -316,8 +316,10 @@ async def add_member(
db,
target.email,
f"You've been added to #{room.name}",
f"You've been added to the #{room.name} room on DS Chat.\n\n"
f"Open the app: {base_url.rstrip('/')}",
[f"You've been added to the #{room.name} room on DS Chat."],
cta_label="Open DS Chat",
cta_url=base_url.rstrip("/"),
theme_user=target,
)
result = await db.execute(
+9 -3
View File
@@ -40,13 +40,19 @@ async def create_site_invite(
await db.refresh(invite)
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(
db,
email,
"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"This link expires in 7 days.",
[
f"You've been invited to join DS Chat by {actor.username}.",
"This link expires in 7 days.",
],
cta_label="Set up your account",
cta_url=signup_link,
)
return invite
+2 -2
View File
@@ -25,8 +25,8 @@ def _recv(ws) -> dict:
def _fake_send_email(monkeypatch):
calls = []
async def fake(db, to, subject, body):
calls.append({"to": to, "subject": subject, "body": body})
async def fake(db, to, subject, paragraphs, **kwargs):
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
monkeypatch.setattr("app.services.room_service.send_email", fake)
return calls
+4 -1
View File
@@ -92,7 +92,10 @@ def test_dm_message_emails_globally_offline_recipient(ws_client_factory, monkeyp
email = calls[0]["message"]
assert email["To"] == bob["email"]
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"/rooms/{dm['id']}" in body
+14 -5
View File
@@ -10,8 +10,17 @@ from tests.conftest import register_and_login
def _fake_send_email(monkeypatch):
calls = []
async def fake(db, to, subject, body):
calls.append({"to": to, "subject": subject, "body": body})
async def fake(db, to, subject, paragraphs, *, cta_label=None, cta_url=None, theme_user=None):
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)
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/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}")
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 client.post("/api/auth/logout")
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.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 client.post("/api/auth/logout")
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(
"/api/auth/reset-password", json={"token": token, "new_password": "firstpass123"}
+2 -2
View File
@@ -450,8 +450,8 @@ async def test_change_member_role_owner_only(client, db_session):
def _fake_send_email(monkeypatch):
calls = []
async def fake(db, to, subject, body):
calls.append({"to": to, "subject": subject, "body": body})
async def fake(db, to, subject, paragraphs, **kwargs):
calls.append({"to": to, "subject": subject, "paragraphs": paragraphs, **kwargs})
monkeypatch.setattr("app.services.room_service.send_email", fake)
return calls
+14 -6
View File
@@ -43,6 +43,14 @@ def _extract_token(body: str) -> str:
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):
await register_and_login(client, db_session, username="alice")
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 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}")
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 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(
"/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"})
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.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 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(
"/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"})
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}")
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 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(
"/api/signup",