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,32 @@
|
|||||||
|
"""room email notifications opt-in
|
||||||
|
|
||||||
|
Revision ID: 05b28b0a2261
|
||||||
|
Revises: e2fc4d65f93e
|
||||||
|
Create Date: 2026-08-28 19:34:23.507200
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '05b28b0a2261'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'e2fc4d65f93e'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('room_memberships', sa.Column('email_notifications', sa.Boolean(), server_default='false', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('room_memberships', 'email_notifications')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -2,7 +2,7 @@ import enum
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
|
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
@@ -42,6 +42,13 @@ class RoomMembership(Base):
|
|||||||
# arrives in the room, or when find_or_create_dm resolves back to it --
|
# arrives in the room, or when find_or_create_dm resolves back to it --
|
||||||
# both count as the conversation being active again.
|
# both count as the conversation being active again.
|
||||||
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# #67: opt-in, per-member -- email when a message in this room mentions
|
||||||
|
# them while they're offline. Deliberately separate from #66's DM
|
||||||
|
# emails (always-on, no toggle) rather than a shared flag, since DMs
|
||||||
|
# are explicitly out of scope for this setting (see message_events.py).
|
||||||
|
email_notifications: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false", nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
room = relationship("Room", back_populates="memberships")
|
room = relationship("Room", back_populates="memberships")
|
||||||
user = relationship("User")
|
user = relationship("User")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.schemas.room import (
|
|||||||
RoomMemberAdd,
|
RoomMemberAdd,
|
||||||
RoomMemberRead,
|
RoomMemberRead,
|
||||||
RoomMemberRoleUpdate,
|
RoomMemberRoleUpdate,
|
||||||
|
RoomNotificationSettingsUpdate,
|
||||||
RoomRead,
|
RoomRead,
|
||||||
RoomUpdate,
|
RoomUpdate,
|
||||||
StartDmRequest,
|
StartDmRequest,
|
||||||
@@ -72,6 +73,7 @@ from app.services.room_service import (
|
|||||||
list_room_members,
|
list_room_members,
|
||||||
mark_room_read,
|
mark_room_read,
|
||||||
remove_member,
|
remove_member,
|
||||||
|
set_room_email_notifications,
|
||||||
transfer_ownership,
|
transfer_ownership,
|
||||||
update_room,
|
update_room,
|
||||||
)
|
)
|
||||||
@@ -179,6 +181,7 @@ async def list_my_rooms_endpoint(
|
|||||||
role=role,
|
role=role,
|
||||||
has_unread=has_unread,
|
has_unread=has_unread,
|
||||||
has_mention=has_mention,
|
has_mention=has_mention,
|
||||||
|
email_notifications=email_notifications,
|
||||||
dm_partner=(
|
dm_partner=(
|
||||||
DmPartnerInfo(
|
DmPartnerInfo(
|
||||||
user_id=partner.id,
|
user_id=partner.id,
|
||||||
@@ -191,7 +194,7 @@ async def list_my_rooms_endpoint(
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for room, role, has_unread, has_mention, partner in rooms
|
for room, role, has_unread, has_mention, email_notifications, partner in rooms
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -293,6 +296,20 @@ async def mark_room_read_endpoint(
|
|||||||
await mark_room_read(db, room_id, current_user.id)
|
await mark_room_read(db, room_id, current_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{room_id}/notifications", status_code=204)
|
||||||
|
async def update_room_notifications_endpoint(
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
data: RoomNotificationSettingsUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await require_room_member(room_id, current_user, db)
|
||||||
|
try:
|
||||||
|
await set_room_email_notifications(db, room_id, current_user.id, data.email_notifications)
|
||||||
|
except CannotModifyDmError:
|
||||||
|
raise HTTPException(status_code=400, detail="Email notifications aren't available for DMs")
|
||||||
|
|
||||||
|
|
||||||
def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
|
def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
|
||||||
# appear_offline always wins, regardless of actual connection -- that's
|
# appear_offline always wins, regardless of actual connection -- that's
|
||||||
# the whole point of the override (lurking in a room undetected).
|
# the whole point of the override (lurking in a room undetected).
|
||||||
|
|||||||
@@ -63,12 +63,20 @@ class MyRoomItem(RoomRead):
|
|||||||
# avatar, not this room's internal `name`) without a second fetch per
|
# avatar, not this room's internal `name`) without a second fetch per
|
||||||
# row. None for a regular room.
|
# row. None for a regular room.
|
||||||
dm_partner: DmPartnerInfo | None = None
|
dm_partner: DmPartnerInfo | None = None
|
||||||
|
# #67: this viewer's own opt-in for "email me when mentioned here while
|
||||||
|
# offline" -- always false for a DM (see message_events.py, deliberately
|
||||||
|
# out of scope; DMs already get #66's automatic offline email).
|
||||||
|
email_notifications: bool = False
|
||||||
|
|
||||||
|
|
||||||
class StartDmRequest(BaseModel):
|
class StartDmRequest(BaseModel):
|
||||||
other_user_id: uuid.UUID
|
other_user_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class RoomNotificationSettingsUpdate(BaseModel):
|
||||||
|
email_notifications: bool
|
||||||
|
|
||||||
|
|
||||||
class RoomMemberRead(BaseModel):
|
class RoomMemberRead(BaseModel):
|
||||||
user_id: uuid.UUID
|
user_id: uuid.UUID
|
||||||
username: str
|
username: str
|
||||||
|
|||||||
@@ -249,6 +249,79 @@ async def _maybe_email_dm_notification(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_email_room_mention_notifications(
|
||||||
|
db: AsyncSession,
|
||||||
|
global_presence: GlobalPresence,
|
||||||
|
base_url: str,
|
||||||
|
room_id: uuid.UUID,
|
||||||
|
sender: User,
|
||||||
|
message: Message,
|
||||||
|
) -> None:
|
||||||
|
"""#67: opt-in, per-room email on mention -- deliberately scoped to
|
||||||
|
regular rooms only (see set_room_email_notifications: DMs already get
|
||||||
|
#66's always-on offline email, no separate toggle). Debounced the same
|
||||||
|
shape as #66 -- only the first unread mention in this room emails, not
|
||||||
|
one per mention while the recipient is away.
|
||||||
|
"""
|
||||||
|
room = await db.get(Room, room_id)
|
||||||
|
if room is None or room.is_dm:
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(MessageMention.user_id).where(MessageMention.message_id == message.id)
|
||||||
|
)
|
||||||
|
mentioned_ids = {row[0] for row in result.all()} - {sender.id}
|
||||||
|
if not mentioned_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
for user_id in mentioned_ids:
|
||||||
|
membership_result = await db.execute(
|
||||||
|
select(RoomMembership).where(
|
||||||
|
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
membership = membership_result.scalar_one_or_none()
|
||||||
|
if membership is None or not membership.email_notifications:
|
||||||
|
continue
|
||||||
|
|
||||||
|
recipient = await db.get(User, user_id)
|
||||||
|
if recipient is None:
|
||||||
|
continue
|
||||||
|
# appear_offline always wins here too, same as _maybe_email_dm_notification.
|
||||||
|
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
|
||||||
|
continue
|
||||||
|
|
||||||
|
already_unread_mention = await db.execute(
|
||||||
|
select(Message.id)
|
||||||
|
.join(MessageMention, MessageMention.message_id == Message.id)
|
||||||
|
.where(
|
||||||
|
MessageMention.user_id == user_id,
|
||||||
|
Message.room_id == room_id,
|
||||||
|
Message.id != message.id,
|
||||||
|
Message.created_at > membership.last_read_at,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if already_unread_mention.scalar_one_or_none() is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
body_line = (
|
||||||
|
f"{sender.username} mentioned you: {message.content[:200]}"
|
||||||
|
if message.content
|
||||||
|
else f"{sender.username} mentioned you"
|
||||||
|
)
|
||||||
|
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
|
||||||
|
await send_email(
|
||||||
|
db,
|
||||||
|
recipient.email,
|
||||||
|
f"New mention in #{room.name}",
|
||||||
|
[body_line],
|
||||||
|
cta_label="Open room",
|
||||||
|
cta_url=link,
|
||||||
|
theme_user=recipient,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_new_message(
|
async def broadcast_new_message(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
broadcaster: Broadcaster,
|
broadcaster: Broadcaster,
|
||||||
@@ -288,6 +361,7 @@ async def broadcast_new_message(
|
|||||||
)
|
)
|
||||||
await _notify_offline_members(db, broadcaster, presence, focus_presence, room_id, sender, message)
|
await _notify_offline_members(db, broadcaster, presence, focus_presence, room_id, sender, message)
|
||||||
await _maybe_email_dm_notification(db, global_presence, base_url, room_id, sender, message)
|
await _maybe_email_dm_notification(db, global_presence, base_url, room_id, sender, message)
|
||||||
|
await _maybe_email_room_mention_notifications(db, global_presence, base_url, room_id, sender, message)
|
||||||
await dispatch_event(db, "message.created", room_id, payload)
|
await dispatch_event(db, "message.created", room_id, payload)
|
||||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro
|
|||||||
|
|
||||||
async def list_member_rooms(
|
async def list_member_rooms(
|
||||||
db: AsyncSession, user_id: uuid.UUID
|
db: AsyncSession, user_id: uuid.UUID
|
||||||
) -> list[tuple[Room, RoomRole, bool, bool, User | None]]:
|
) -> list[tuple[Room, RoomRole, bool, bool, bool, User | None]]:
|
||||||
last_message_at = (
|
last_message_at = (
|
||||||
select(func.max(Message.created_at))
|
select(func.max(Message.created_at))
|
||||||
.where(Message.room_id == Room.id)
|
.where(Message.room_id == Room.id)
|
||||||
@@ -205,7 +205,12 @@ async def list_member_rooms(
|
|||||||
)
|
)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(
|
select(
|
||||||
Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention
|
Room,
|
||||||
|
RoomMembership.role,
|
||||||
|
RoomMembership.last_read_at,
|
||||||
|
last_message_at,
|
||||||
|
has_unread_mention,
|
||||||
|
RoomMembership.email_notifications,
|
||||||
)
|
)
|
||||||
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
||||||
.where(RoomMembership.user_id == user_id, RoomMembership.hidden_at.is_(None))
|
.where(RoomMembership.user_id == user_id, RoomMembership.hidden_at.is_(None))
|
||||||
@@ -238,9 +243,10 @@ async def list_member_rooms(
|
|||||||
role,
|
role,
|
||||||
last_message_at is not None and last_message_at > last_read_at,
|
last_message_at is not None and last_message_at > last_read_at,
|
||||||
has_mention,
|
has_mention,
|
||||||
|
email_notifications,
|
||||||
partners_by_room.get(room.id),
|
partners_by_room.get(room.id),
|
||||||
)
|
)
|
||||||
for room, role, last_read_at, last_message_at, has_mention in rows
|
for room, role, last_read_at, last_message_at, has_mention, email_notifications in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -486,6 +492,22 @@ async def mark_room_read(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUI
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def set_room_email_notifications(
|
||||||
|
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, enabled: bool
|
||||||
|
) -> None:
|
||||||
|
"""#67: DMs are deliberately excluded -- they already get #66's
|
||||||
|
automatic offline email with no opt-in needed, and this setting only
|
||||||
|
makes sense for a regular room's mention-based notifications."""
|
||||||
|
room = await db.get(Room, room_id)
|
||||||
|
if room is None:
|
||||||
|
raise RoomNotFoundError()
|
||||||
|
if room.is_dm:
|
||||||
|
raise CannotModifyDmError()
|
||||||
|
membership = await _get_membership(db, room_id, user_id)
|
||||||
|
membership.email_notifications = enabled
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||||
membership = await _get_membership(db, room_id, user_id)
|
membership = await _get_membership(db, room_id, user_id)
|
||||||
if membership.role == RoomRole.owner:
|
if membership.role == RoomRole.owner:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -68,6 +68,16 @@ export function hideDm(roomId: string): Promise<void> {
|
|||||||
return apiFetch<void>(`/api/rooms/${roomId}/hide`, { method: 'POST' })
|
return apiFetch<void>(`/api/rooms/${roomId}/hide`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #67: per-viewer opt-in for email-on-mention in this room. 400s for a DM
|
||||||
|
// (backend rejects it -- see set_room_email_notifications), so callers
|
||||||
|
// should only expose the toggle for a non-DM room.
|
||||||
|
export function updateRoomNotifications(roomId: string, emailNotifications: boolean): Promise<void> {
|
||||||
|
return apiFetch<void>(`/api/rooms/${roomId}/notifications`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ email_notifications: emailNotifications }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
|
export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
|
||||||
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
|
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
removeMember,
|
removeMember,
|
||||||
transferOwnership,
|
transferOwnership,
|
||||||
updateRoom,
|
updateRoom,
|
||||||
|
updateRoomNotifications,
|
||||||
} from '../api/rooms'
|
} from '../api/rooms'
|
||||||
import {
|
import {
|
||||||
createEventSubscription,
|
createEventSubscription,
|
||||||
@@ -106,6 +107,8 @@ export function RoomInfoPanel({
|
|||||||
const [descDraft, setDescDraft] = useState(room.description ?? '')
|
const [descDraft, setDescDraft] = useState(room.description ?? '')
|
||||||
const [isPrivateDraft, setIsPrivateDraft] = useState(room.is_private)
|
const [isPrivateDraft, setIsPrivateDraft] = useState(room.is_private)
|
||||||
const [roomError, setRoomError] = useState<string | null>(null)
|
const [roomError, setRoomError] = useState<string | null>(null)
|
||||||
|
const [emailNotifications, setEmailNotifications] = useState(room.email_notifications)
|
||||||
|
const [notificationsError, setNotificationsError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [integrationsOpen, setIntegrationsOpen] = useState(false)
|
const [integrationsOpen, setIntegrationsOpen] = useState(false)
|
||||||
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncoming[]>([])
|
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncoming[]>([])
|
||||||
@@ -129,6 +132,7 @@ export function RoomInfoPanel({
|
|||||||
setNameDraft(room.name)
|
setNameDraft(room.name)
|
||||||
setDescDraft(room.description ?? '')
|
setDescDraft(room.description ?? '')
|
||||||
setIsPrivateDraft(room.is_private)
|
setIsPrivateDraft(room.is_private)
|
||||||
|
setEmailNotifications(room.email_notifications)
|
||||||
if (canManage) {
|
if (canManage) {
|
||||||
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
|
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
|
||||||
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
|
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
|
||||||
@@ -138,7 +142,7 @@ export function RoomInfoPanel({
|
|||||||
setEventSubscriptions([])
|
setEventSubscriptions([])
|
||||||
setDirectoryUsers([])
|
setDirectoryUsers([])
|
||||||
}
|
}
|
||||||
}, [room.id, room.name, room.description, room.is_private, canManage])
|
}, [room.id, room.name, room.description, room.is_private, room.email_notifications, canManage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetched lazily (only once expanded), not alongside the section above
|
// Fetched lazily (only once expanded), not alongside the section above
|
||||||
@@ -152,6 +156,19 @@ export function RoomInfoPanel({
|
|||||||
.catch((err) => setAttachmentsError(err instanceof ApiError ? err.message : String(err)))
|
.catch((err) => setAttachmentsError(err instanceof ApiError ? err.message : String(err)))
|
||||||
}, [filesOpen, room.id])
|
}, [filesOpen, room.id])
|
||||||
|
|
||||||
|
async function handleToggleEmailNotifications(enabled: boolean) {
|
||||||
|
const previous = emailNotifications
|
||||||
|
setEmailNotifications(enabled)
|
||||||
|
setNotificationsError(null)
|
||||||
|
try {
|
||||||
|
await updateRoomNotifications(room.id, enabled)
|
||||||
|
onRoomUpdated()
|
||||||
|
} catch (err) {
|
||||||
|
setEmailNotifications(previous)
|
||||||
|
setNotificationsError(err instanceof ApiError ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAddMember(target: UserDirectoryEntry) {
|
async function handleAddMember(target: UserDirectoryEntry) {
|
||||||
setInviteError(null)
|
setInviteError(null)
|
||||||
try {
|
try {
|
||||||
@@ -377,6 +394,26 @@ export function RoomInfoPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!room.is_dm && (
|
||||||
|
<div className="room-info-section">
|
||||||
|
<div className="toggle-row">
|
||||||
|
<div className="toggle-label">
|
||||||
|
<span className="t">Email me on mentions</span>
|
||||||
|
<span className="d">Sent only while you're offline</span>
|
||||||
|
</div>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailNotifications}
|
||||||
|
onChange={(e) => handleToggleEmailNotifications(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{notificationsError && <p className="room-info-error">{notificationsError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="room-info-section">
|
<div className="room-info-section">
|
||||||
<button type="button" className="room-info-settings-toggle" onClick={() => setFilesOpen((v) => !v)}>
|
<button type="button" className="room-info-settings-toggle" onClick={() => setFilesOpen((v) => !v)}>
|
||||||
<DisclosureChevron open={filesOpen} /> Files
|
<DisclosureChevron open={filesOpen} /> Files
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export interface MyRoomItem extends Room {
|
|||||||
// #52: the other participant, only for is_dm rooms -- see backend
|
// #52: the other participant, only for is_dm rooms -- see backend
|
||||||
// schemas/room.py's MyRoomItem for why this is precomputed server-side.
|
// schemas/room.py's MyRoomItem for why this is precomputed server-side.
|
||||||
dm_partner: DmPartnerInfo | null
|
dm_partner: DmPartnerInfo | null
|
||||||
|
// #67: this viewer's own opt-in for "email me when mentioned here while
|
||||||
|
// offline" -- always false for a DM (see backend's message_events.py).
|
||||||
|
email_notifications: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomMember {
|
export interface RoomMember {
|
||||||
|
|||||||
Reference in New Issue
Block a user