Skip to content

Email webchat visitors messages they didn't read - #6

Merged
rowanseymour merged 2 commits into
mainfrom
rowan/webchat-email-fallback-2
Oct 29, 2026
Merged

rowanseymour merged 2 commits into
mainfrom
rowan/webchat-email-fallback-2

Conversation

@rowanseymour

@rowanseymour rowanseymour commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Builds on #5. When a channel is configured for email and the contact has an address, chip holds back each message it delivers. Once the conversation has gone quiet, it emails the visitor the messages they still haven't read. Courier never sends email, and chip still only reads the platform's database.

Changes

  • Holding back (web/internal.go, core/email/pending.go): after a successful publish, /wi/send holds the message in valkey: the address, the message UUIDs, and when the oldest was held.
    • A conversation is due 3 minutes after its latest message, but never more than 30 minutes after its oldest.
    • A due time only ever moves later, so holding back another message never cuts short the wait before a retry.
    • A send with no email means the contact no longer has one, so anything held back for them is dropped.
    • Failing to hold a message back is logged and never fails the send.
  • Sweeper (core/email/sweeper.go, cmd/service.go): every instance runs one, ticking every 30s. Claiming a due conversation is one atomic script that also removes its state, so only one instance emails it. A crash mid-send loses that email rather than sending it twice.
    • At send time the sweeper re-checks the channel's email config and that the contact is still active.
    • It emails only messages that are still unread, visible and outgoing. If the visitor read them all, nothing is sent.
  • Retries: a send that may succeed if tried again (Mailgun unreachable, 429, 5xx) is retried after 1, 5 and 15 minutes, then dropped. Messages held back while it waits join the batch being retried. Anything else Mailgun refuses, or an address it can't be sent to, is dropped straight away.
  • Models: GetUnreadChatMsgs loads the batch, and LoadOrg supplies the sender name and the timezone the email's times are shown in.

Test plan

  • TestGetUnreadChatMsgs, TestLoadOrg
  • TestSweeper:
    • debounce and cap, latest address wins
    • only unread messages emailed; all read means no email
    • drop, and concurrent sweeps emailing once
    • transient failures retried with backoff, then dropped
    • a hold during backoff doesn't shorten it, and joins the retried batch
    • permanent failures and bad addresses dropped
    • channel no longer configured, or contact released, skipped
  • TestSendHoldsForEmail: held back when configured with an address; dropped by a send without one; not held when the publish fails
  • go test ./... passes in the devcontainer

Comment thread core/email/pending.go Resolved
Comment thread web/internal.go Resolved
@claude

claude Bot commented Oct 29, 2026

Copy link
Copy Markdown

Review summary

The design looks solid: claiming is atomic, so only one sweeper emails a conversation, and a crash loses an email rather than sending it twice. Errors are split into retryable and permanent, a failure to hold a message never fails the send, and the sweeper re-checks the channel config, the contact and the read status when it sends.

Main issues (inline):

  • Holds override the retry backoff (core/email/pending.go): a message held while a batch waits to retry, or while a send is in flight, moves the conversation's due time to now + 3m. The 1/5/15m backoff is skipped in an active conversation, which hurts most on Mailgun 429s. The leftover attempts count also means newly held messages get fewer retries.
  • Extra DB query per send (web/internal.go): LoadChannel now runs on every courier send that has an email. The sweeper already checks the config again, so this check could probably go.

Test gaps:

  • No test of a hold arriving during retry backoff, or while a send is in flight, where the requeue's merge path runs.
  • No test that GetUnreadChatMsgs returns messages oldest first when UUID order and created_on order differ. The query sorts by uuid, which works for UUIDv7 but is less explicit than sorting by created_on.

🤖 Generated with Claude Code

@rowanseymour
rowanseymour merged commit e709eee into main Oct 29, 2026
2 checks passed
@rowanseymour
rowanseymour deleted the rowan/webchat-email-fallback-2 branch October 29, 2026 19:27
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant