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
+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"}