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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user