Private
Public Access
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>
208 lines
8.1 KiB
Python
208 lines
8.1 KiB
Python
import uuid
|
|
|
|
from app.models import SmtpSettings
|
|
from app.schemas.user import UserCreate
|
|
from app.services.auth_service import register_user
|
|
|
|
|
|
def _unique(prefix: str) -> str:
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
def _fake_smtp(monkeypatch):
|
|
calls = []
|
|
|
|
async def fake_send(message, **kwargs):
|
|
calls.append({"message": message, **kwargs})
|
|
|
|
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
|
|
|
# Monkeypatches get_smtp_settings directly rather than configuring it
|
|
# for real through the admin endpoint (like test_smtp_settings.py's own
|
|
# _configure_smtp does) -- ws_client_factory-based tests commit for
|
|
# real, no rollback, and SmtpSettings is a genuine single global row.
|
|
# Configuring it for real here previously leaked into every later test
|
|
# in the same run, breaking test_smtp_settings.py's "starts
|
|
# unconfigured" assumption. This never touches the DB at all.
|
|
fake_settings = SmtpSettings(
|
|
host="smtp.example.com",
|
|
port=587,
|
|
username="bot",
|
|
password_encrypted=None,
|
|
from_address="noreply@example.com",
|
|
use_tls=True,
|
|
)
|
|
|
|
async def fake_get_smtp_settings(db):
|
|
return fake_settings
|
|
|
|
monkeypatch.setattr("app.services.email_service.get_smtp_settings", fake_get_smtp_settings)
|
|
return calls
|
|
|
|
|
|
def _register_ws(ws_client, username: str) -> dict:
|
|
async def _seed():
|
|
async with ws_client.session_factory() as session:
|
|
await register_user(
|
|
session,
|
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
|
)
|
|
|
|
ws_client.portal.call(_seed)
|
|
resp = ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
def _send_and_sync(ws, room_id: str, content: str) -> dict:
|
|
"""Sync barrier -- see test_mentions.py's identical helper. The
|
|
"message" ack fires the instant broadcaster.publish() runs, the very
|
|
first line of broadcast_new_message -- it proves nothing about whether
|
|
_maybe_email_dm_notification (awaited afterward, in the same handler)
|
|
has finished. A second, idempotent join's own ack only arrives once
|
|
the whole prior frame's handling -- including the email step -- is
|
|
done, since one connection processes frames strictly sequentially."""
|
|
ws.send_json({"type": "message", "room_id": room_id, "content": content})
|
|
message = ws.receive_json()
|
|
ws.send_json({"type": "join", "room_id": room_id})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
return message
|
|
|
|
|
|
def test_dm_message_emails_globally_offline_recipient(ws_client_factory, monkeypatch):
|
|
calls = _fake_smtp(monkeypatch)
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
|
|
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
|
|
|
# bob never connects via WS at all -- genuinely offline, not just
|
|
# absent from this room's own channel.
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
_send_and_sync(alice_ws, dm["id"], "hey, you there?")
|
|
|
|
assert len(calls) == 1
|
|
email = calls[0]["message"]
|
|
assert email["To"] == bob["email"]
|
|
assert f"New message from {alice['username']}" in email["Subject"]
|
|
# #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
|
|
|
|
|
|
def test_dm_message_does_not_email_online_recipient(ws_client_factory, monkeypatch):
|
|
calls = _fake_smtp(monkeypatch)
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
|
|
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
|
|
|
with instance2.websocket_connect("/ws/chat"):
|
|
# bob has an open connection -- genuinely online -- even though he
|
|
# never joins the DM's own room channel.
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
_send_and_sync(alice_ws, dm["id"], "hey")
|
|
|
|
assert calls == []
|
|
|
|
|
|
def test_dm_message_emails_appear_offline_recipient_even_when_connected(ws_client_factory, monkeypatch):
|
|
calls = _fake_smtp(monkeypatch)
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
|
|
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
|
|
|
resp = instance2.patch("/api/auth/me", json={"appear_offline": True})
|
|
assert resp.status_code == 200
|
|
|
|
with instance2.websocket_connect("/ws/chat"):
|
|
# bob is connected (genuinely online) but lurking -- appear_offline
|
|
# should still count as "email me," matching how it already
|
|
# overrides the presence dot everywhere else.
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
_send_and_sync(alice_ws, dm["id"], "hey")
|
|
|
|
assert len(calls) == 1
|
|
assert calls[0]["message"]["To"] == bob["email"]
|
|
|
|
|
|
def test_regular_room_message_does_not_email_offline_member(ws_client_factory, monkeypatch):
|
|
calls = _fake_smtp(monkeypatch)
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
_register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
_register_ws(instance2, _unique("bob"))
|
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
|
# bob never connects -- genuinely offline, same as the DM case -- but
|
|
# this isn't a DM, so #66's email notification is out of scope here.
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
_send_and_sync(alice_ws, room["id"], "hello room")
|
|
|
|
assert calls == []
|
|
|
|
|
|
def test_dm_email_debounced_to_first_unread_then_resets_after_read(ws_client_factory, monkeypatch):
|
|
calls = _fake_smtp(monkeypatch)
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
|
|
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
|
|
_send_and_sync(alice_ws, dm["id"], "message one")
|
|
assert len(calls) == 1
|
|
|
|
# A second message while bob still hasn't read the first -- no
|
|
# second email for the same burst.
|
|
_send_and_sync(alice_ws, dm["id"], "message two")
|
|
assert len(calls) == 1
|
|
|
|
# bob "reads" the conversation via REST -- he never has to have been
|
|
# connected via WS for this to be meaningful, mark-read is independent
|
|
# of live connection state.
|
|
instance2.post(
|
|
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
|
)
|
|
read_resp = instance2.post(f"/api/rooms/{dm['id']}/read")
|
|
assert read_resp.status_code == 204
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": dm["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
_send_and_sync(alice_ws, dm["id"], "message three")
|
|
|
|
assert len(calls) == 2
|