Skip to content

feat: email a recipient about messages that arrive while they are away - #2

Merged
kenchou2006 merged 1 commit into
mainfrom
chat-unread-email-notifications
Sep 13, 2026
Merged

kenchou2006 merged 1 commit into
mainfrom
chat-unread-email-notifications

Conversation

@kenchou2006

@kenchou2006 kenchou2006 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

未讀通知的第一段:判斷收訊者是否「不在線上」,以及每個對話只寄一次的去重。實際寄信在 UniBooks-BE 那側。

規則

  1. 只有完全沒連線到 CFEdgeChat 才寄 — 依據是收訊者的 UserHub 上有沒有 WebSocket。前端每次造訪整站只開一條,所以「沒打開那個對話」不算離線,「沒打開網頁」才算。ChatRoom 原有的 is_offline(房間裡沒有別人)是另一個問題,維持只驅動 Django 端的 log。
  2. 同一個對話在被點開前只寄一次emailNotifiedRooms 記錄已寄過的 room,收訊者點開對話(既有的 mark-read 路徑)時清除。

標記是在送出請求「之前」寫入的:送信是沒人等待結果的 waitUntil,Django 掛掉時漏一封,好過它掛著的期間每則訊息各寄一封。

實作

Worker 不自己寄信,而是呼叫既有的 DJANGO_WEBHOOK_URL,用 event 欄位區分;信件模板、收件地址、以及「這個人到底該不該收信」(停用帳號、自己刪掉的對話)都留在 Django。

  • offlineEmailPolicy.ts(新):判斷邏輯抽成純函式,不需要 Durable Object 或 workerd 就能測,比照旁邊的 image URL policy。
  • webhookUrlPolicy.ts(新):ChatRoom 原本私有的 webhook URL 檢查移出共用 — 兩個 DO 都會對外呼叫了,這道 SSRF 防護不能變成「只有其中一個記得做」。

驗證

  • 單元測試 40 passed、npm run typecheck 乾淨。
  • 真實的 Django 與 Mailjet(不是 mock)跑完整流程:四則訊息、中間插入一次 mark-read,剛好寄出兩封(第一則、以及點開後的那則);收訊者 hub 連著時發訊息則一封都沒有。

相依

需要搭配 CycleUni/UniBooks-BE 的對應 PR 才會真的寄出信件;單獨合併此 PR 只會讓 webhook 多送一個 Django 尚未認識的 event,Django 會走既有分支處理、不會出錯。

The unread badge only reaches someone who has the site open. Nothing
told the person who had closed it.

A recipient counts as away when their UserHub holds no WebSocket: the
frontend opens one per visit covering the whole site, so a closed
conversation is not being away, a closed site is. ChatRoom's existing
is_offline flag answers a different question (nobody else in *this*
room) and still drives only the log line on Django's side.

At most one email per conversation per unread streak. The hub records
the room in emailNotifiedRooms and clears it when the recipient opens
the conversation, via the mark-read path the frontend already calls.
The mark is written before the request goes out, not after: the send is
a fire-and-forget waitUntil nobody awaits, and one missed email while
Django is down beats one email per message for as long as it stays down.

The Worker does not send mail itself. It calls the DJANGO_WEBHOOK_URL it
already has, with an `event` discriminator, and Django owns the
templates, the address, and every "should this person be mailed at all"
rule — a deactivated account, a conversation they deleted.

The decision is a pure function in offlineEmailPolicy.ts so it can be
tested without a Durable Object or a workerd runtime, like the image URL
policy next to it. ChatRoom's private webhook-URL guard moved to
webhookUrlPolicy.ts now that both DOs call out, so the SSRF check cannot
end up being the one UserHub forgot.

Verified end to end against a real Django and Mailjet, not a mock: four
messages with a mark-read in the middle produced exactly two emails (the
first, and the one after the read), and a message sent while the
recipient's hub was connected produced none.
Copilot AI lite review requested due to automatic review settings September 13, 2026 06:12
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
cfedgechat 7f6e4c2 Sep 13 2026, 06:10 AM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants