Skip to content

Add groundwork for emailing webchat visitors messages they didn't see - #5

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

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

Conversation

@rowanseymour

@rowanseymour rowanseymour commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Summary

This is the groundwork for emailing webchat visitors the outgoing messages they didn't read in the chat. It adds the channel's email config, a Mailgun client and the email rendering. Nothing calls them yet, so behaviour doesn't change. A follow-up PR adds buffering of sent messages, a sweep that checks read status, and the actual sending.

Changes

  • core/models/channel.go: Channel.EmailConfig() reads the channel's email_from, mailgun_domain, mailgun_region and mailgun_api_key config. It returns nil unless the from address, domain and key are all set to strings, because a channel with only some of them can't send.
  • core/email/mailgun.go: Send posts to Mailgun's /v3/{domain}/messages with basic auth using the domain sending key.
    • It sends from "<name>" <email_from> and sets the channel UUID as a Mailgun user variable, so later events and webhooks can be matched to the channel.
    • The region is case-insensitive and defaults to US when it's empty.
    • ErrUnsendable covers emails that are never sent at all: an unknown region, or a recipient that isn't exactly one address. Mailgun accepts a list in to, but a visitor's email must only reach them.
    • When Mailgun itself refuses a message, the result is a SendError.
    • IsRetryable treats network errors, 429 and 5xx as worth retrying. Other 4xx errors and unsendable emails are permanent.
  • core/email/render.go: Render builds a multipart email (text and HTML from embedded templates) for a batch of messages.
    • Each message shows its sender (the user who sent it, or the workspace name for flow messages) and its time in the workspace's timezone.
    • Text is escaped, and line breaks are kept in the HTML.
    • Images show inline. Other attachments are linked, named from their decoded URL path, so a signed URL's query string doesn't appear in the link text.
    • Text quick replies are listed as plain options, since they can't be answered by email.
  • runtime/http.go: a separate HTTP client for Mailgun with a 30s timeout.
  • web/internal.go: /wi/send accepts the contact's optional email. It isn't used yet and is never published to the chat. It's checked when sending rather than here, because rejecting the send would also fail the message for the chat.

Example

For two unread messages, the text part looks like this:

Nyaruka (Oct 13, 13:20):
Hi there!
How can we help?

Andy Admin (Oct 13, 13:21):
Here's our menu
https://example.com/menu.jpg
Options: Pizza · Pasta

--
You're receiving this because you chatted with Nyaruka and haven't seen these messages yet. To reply, continue the chat on our website.

The subject is New message from <workspace>, or New messages from <workspace> for more than one.

Test plan

  • TestChannelEmailConfig covers a complete config, each way it can be incomplete, and values that aren't strings
  • TestRender covers:
    • the text output
    • HTML escaping of text and sender names
    • line breaks
    • inline images vs linked files
    • names from signed and percent-encoded URLs
    • a javascript: attachment URL neutralized in the HTML
    • quick replies with and without text
    • the subject singular/plural
  • TestSend (email) covers:
    • the request path per region, with case-insensitivity and the default region
    • auth and form fields
    • unknown regions and invalid or multiple recipients refused without calling Mailgun
    • whether each refusal status and an unreachable server are retryable
  • TestSend (web) checks that email is accepted on /wi/send and isn't published
  • go test ./... passes in the devcontainer

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

claude Bot commented Oct 28, 2026

Copy link
Copy Markdown

The groundwork is clear and well tested. I found three things worth fixing before the follow-up PR starts calling this code; details are in the inline comments:

  • email on /wi/send isn't validated (web/internal.go). It's passed straight to Mailgun's to field, which takes a comma-separated list. A malformed value or a list of addresses would either email several recipients or fail permanently later. Add validate:"omitempty,email", like the other fields in that payload.
  • Unknown mailgun_region values fall back to US (core/email/mailgun.go). An "EU" or a typo on an EU domain makes every send fail with a non-retryable 4xx, and the emails are dropped.
  • Attachment names come from the raw URL (core/email/render.go). For signed URLs the link text includes the query string and percent-encoding. Use the parsed, decoded URL path instead.

Two test gaps:

  • Render has no test where the HTML escapes an attachment URL or sender name, e.g. a javascript: attachment URL. It also has no test for a message with quick replies and no text.
  • Nothing tests what EmailConfig does when a config value isn't a string.

@rowanseymour

Copy link
Copy Markdown
Member Author

Added the tests the review asked for: escaping of sender names and attachment URLs (a javascript: URL becomes #ZgotmplZ), quick replies on a message with no text, and EmailConfig when a config value isn't a string.

@rowanseymour
rowanseymour merged commit b8e71e3 into main Oct 29, 2026
2 checks passed
@rowanseymour
rowanseymour deleted the rowan/webchat-email-fallback-e36f6d branch October 29, 2026 16:48
@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