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