Email a DM's recipient when they're genuinely offline (#66)

Scoped to direct messages only, and deliberately narrower than the
existing push/desktop "offline" (not connected to this room's channel
right now, which fires on every message) -- email uses GlobalPresence
instead (no open connection anywhere, or appear_offline), since the
other participant could easily just be active in a different room.
Debounced to the first unread message in the conversation rather than
firing on every message in a burst, resetting once they mark it read.
Reuses the existing SMTP/send_email infrastructure from the invite
feature, so it silently no-ops if SMTP isn't configured, same as
everywhere else that already uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 17:19:01 -06:00
co-authored by Claude Sonnet 5
parent ef615e1ef4
commit 7b44ce325c
4 changed files with 299 additions and 2 deletions
+12 -1
View File
@@ -28,4 +28,15 @@ async def incoming_webhook_endpoint(
broadcaster = request.app.state.broadcaster
presence = request.app.state.presence
focus_presence = request.app.state.focus_presence
await broadcast_new_message(db, broadcaster, presence, focus_presence, room.id, message, sender)
global_presence = request.app.state.global_presence
await broadcast_new_message(
db,
broadcaster,
presence,
focus_presence,
global_presence,
str(request.base_url),
room.id,
message,
sender,
)
+74
View File
@@ -6,12 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User
from app.schemas.message import ReactionSummary
from app.services.email_service import send_email
from app.services.link_preview_service import fetch_and_broadcast_link_preview
from app.services.push_service import send_push_to_user
from app.services.room_service import list_dm_partner_ids
from app.services.webhook_service import dispatch_event
from app.ws.broadcaster import Broadcaster
from app.ws.focus_presence import FocusPresence
from app.ws.global_presence import GlobalPresence
from app.ws.presence import Presence
@@ -148,11 +150,82 @@ def _maybe_fetch_link_preview(broadcaster: Broadcaster, room_id: uuid.UUID, mess
)
async def _maybe_email_dm_notification(
db: AsyncSession,
global_presence: GlobalPresence,
base_url: str,
room_id: uuid.UUID,
sender: User,
message: Message,
) -> None:
"""#66: DMs only, deliberately narrower than _notify_offline_members'
own "offline" -- that one means "not connected to this room's channel
right now," which fires on every message and is fine for a lightweight
channel (push/desktop). Email is heavier-weight and a DM's other
participant could easily be actively using the app in a different room,
so this uses GlobalPresence (genuinely no open connection anywhere)
instead -- the same "is this user actually offline" logic as
rooms.py's private _member_status (not importable from here), just
re-derived.
"""
room = await db.get(Room, room_id)
if room is None or not room.is_dm:
return
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id != sender.id
)
)
membership = result.scalar_one_or_none()
if membership is None:
return
recipient = await db.get(User, membership.user_id)
if recipient is None:
return
# appear_offline is a manual "always look offline" override -- treated
# the same as genuinely offline here, same as everywhere else it's
# checked in this codebase.
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
return
# Debounced to the first unread message in this conversation, not
# every single one -- a burst of DMs while someone's asleep should be
# one email, not one per message.
already_unread = await db.execute(
select(Message.id)
.where(
Message.room_id == room_id,
Message.id != message.id,
Message.created_at > membership.last_read_at,
)
.limit(1)
)
if already_unread.scalar_one_or_none() is not None:
return
if message.content:
body_line = f"{sender.username}: {message.content[:200]}"
elif message.file_id:
body_line = f"{sender.username} sent a file"
else:
body_line = f"{sender.username} sent an image"
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
await send_email(
db,
recipient.email,
f"New message from {sender.username}",
f"{body_line}\n\nView it here:\n{link}",
)
async def broadcast_new_message(
db: AsyncSession,
broadcaster: Broadcaster,
presence: Presence,
focus_presence: FocusPresence,
global_presence: GlobalPresence,
base_url: str,
room_id: uuid.UUID,
message: Message,
sender: User,
@@ -184,6 +257,7 @@ async def broadcast_new_message(
unhidden_user_id, {"type": "room_added", "room_id": str(room_id)}
)
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 dispatch_event(db, "message.created", room_id, payload)
_maybe_fetch_link_preview(broadcaster, room_id, message)
+9 -1
View File
@@ -230,7 +230,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
# attributed sender may not actually be watching.
await mark_room_read(db, envelope.room_id, user.id)
await broadcast_new_message(
db, broadcaster, presence, focus_presence, envelope.room_id, message, user
db,
broadcaster,
presence,
focus_presence,
global_presence,
str(websocket.base_url),
envelope.room_id,
message,
user,
)
elif envelope.type == "edit":