Private
Public Access
Add per-room opt-in email notifications on mention (#67)
Lets a member subscribe to email when they're @mentioned in a room while offline, alongside #66's always-on DM email. Debounced the same way #66 is (one email per unread burst, not one per mention), and deliberately scoped to regular rooms only -- DMs already have #66's automatic offline email with no separate toggle needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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)
|
||||
|
||||
# See test_dm_email_notifications.py's identical helper for why this
|
||||
# monkeypatches get_smtp_settings directly instead of configuring a real
|
||||
# row through the admin endpoint.
|
||||
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:
|
||||
"""See test_dm_email_notifications.py's identical helper -- the
|
||||
"message" ack alone proves nothing about whether the email step
|
||||
(awaited afterward in the same handler) has finished; a second,
|
||||
idempotent join's ack only arrives once the whole frame is done."""
|
||||
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 _subscribe(ws_client, room_id: str, username: str, password: str = "password123") -> None:
|
||||
login = ws_client.post(
|
||||
"/api/auth/login", json={"username_or_email": username, "password": password}
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
resp = ws_client.patch(f"/api/rooms/{room_id}/notifications", json={"email_notifications": True})
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
|
||||
def test_room_mention_emails_offline_subscribed_member(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"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
# bob never connects via WS -- genuinely offline.
|
||||
|
||||
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"], f"hey @{bob['username']}, look at this")
|
||||
|
||||
assert len(calls) == 1
|
||||
email = calls[0]["message"]
|
||||
assert email["To"] == bob["email"]
|
||||
assert f"New mention in #{room['name']}" in email["Subject"]
|
||||
body = email.get_body(preferencelist=("plain",)).get_content()
|
||||
assert alice["username"] in body
|
||||
assert f"/rooms/{room['id']}" in body
|
||||
|
||||
|
||||
def test_room_message_without_mention_does_not_email_subscribed_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
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, no mention here")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_room_mention_does_not_email_unsubscribed_member(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"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
# bob never opts in -- email_notifications defaults to False.
|
||||
|
||||
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"], f"hey @{bob['username']}")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_room_mention_does_not_email_online_subscribed_member(ws_client_factory, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
with instance2.websocket_connect("/ws/chat"):
|
||||
# bob has an open connection -- genuinely online -- even though he
|
||||
# never joins this room's own channel.
|
||||
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"], f"hey @{bob['username']}")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_room_mention_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()
|
||||
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
bob = _register_ws(instance2, _unique("bob"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
instance2.post(f"/api/rooms/{room['id']}/join")
|
||||
_subscribe(instance2, room["id"], bob["username"])
|
||||
|
||||
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"], f"@{bob['username']} first mention")
|
||||
assert len(calls) == 1
|
||||
|
||||
# A second mention while bob still hasn't read the first -- no
|
||||
# second email for the same burst.
|
||||
_send_and_sync(alice_ws, room["id"], f"@{bob['username']} second mention")
|
||||
assert len(calls) == 1
|
||||
|
||||
instance2.post(
|
||||
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
|
||||
)
|
||||
read_resp = instance2.post(f"/api/rooms/{room['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": room["id"]})
|
||||
assert alice_ws.receive_json()["type"] == "joined"
|
||||
_send_and_sync(alice_ws, room["id"], f"@{bob['username']} third mention")
|
||||
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_enabling_notifications_rejected_for_dm(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
instance2 = ws_client_factory()
|
||||
|
||||
_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 = instance1.patch(f"/api/rooms/{dm['id']}/notifications", json={"email_notifications": True})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_notifications_setting_reflected_in_my_rooms(ws_client_factory):
|
||||
instance1 = ws_client_factory()
|
||||
_register_ws(instance1, _unique("alice"))
|
||||
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
||||
|
||||
rooms = instance1.get("/api/rooms/mine").json()
|
||||
mine = next(r for r in rooms if r["id"] == room["id"])
|
||||
assert mine["email_notifications"] is False
|
||||
|
||||
resp = instance1.patch(f"/api/rooms/{room['id']}/notifications", json={"email_notifications": True})
|
||||
assert resp.status_code == 204
|
||||
|
||||
rooms = instance1.get("/api/rooms/mine").json()
|
||||
mine = next(r for r in rooms if r["id"] == room["id"])
|
||||
assert mine["email_notifications"] is True
|
||||
Reference in New Issue
Block a user