#21: push new mail to the browser over WebSocket

F4 lists real-time updates and the interface had none: an open inbox only
changed when the user reloaded it.

Events come from the transactional outbox rather than from an in-process
callback, which is what the outbox is for (PLAN.md section 9.4): the row
is written in the same transaction as the message, so an event can never
describe a delivery that rolled back, and a crash between commit and
publish loses nothing because the row is still there on restart. The relay
claims rows with SKIP LOCKED so a second instance can help without
publishing anything twice.

The hub fans events out per mailbox, keyed by the mailbox the session
resolved to, so one account's arrivals are never visible to another even
though they share the process. A subscriber that stops reading is dropped
rather than allowed to stall the relay; it reconciles on reconnect.

The client reconnects with capped exponential backoff and treats every
successful connect as a resync, refetching the folder. Without that, a
socket that dropped for a minute would leave the list quietly stale, which
is worse than having no real-time updates at all.

The upgrade goes through a Next rewrite, not the API route handler: a
route handler sees a finished request and cannot upgrade a connection.
Verified against the running stack - the handshake returns 101 through the
Next server, and a message delivered on port 25 arrives as a frame on an
already-open socket without the page asking for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 10:26:54 -03:00
1 parent 3e60852551
commit 93d21b4752
12 files changed
+643

No files matched your search

@@ -0,0 +1,191 @@
package httppresentation
import (
"context"
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
// subscriberBuffer is how many events may queue for one connection before it
// is considered stuck. A browser tab that stops reading must not be able to
// hold the relay's memory: it is dropped and reconciles on reconnect.
const subscriberBuffer = 32
// EventMessage is what a subscriber receives.
type EventMessage struct {
Type string `json:"type"`
MailboxID string `json:"mailbox_id"`
Payload json.RawMessage `json:"payload"`
}
type subscriber struct {
mailboxID string
events chan EventMessage
}
// EventHub fans domain events out to the browser over WebSocket.
//
// Delivery is per mailbox: an event is only sent to connections whose session
// resolved to that mailbox, so one account's arrivals are never visible to
// another even though they share this process.
type EventHub struct {
mu sync.RWMutex
subscribers map[*subscriber]struct{}
sessions *WebSessionVerifier
mailboxes *usecase.WebmailUseCase
}
func NewEventHub(sessions *WebSessionVerifier, mailboxes *usecase.WebmailUseCase) *EventHub {
return &EventHub{
subscribers: map[*subscriber]struct{}{},
sessions: sessions,
mailboxes: mailboxes,
}
}
// Publish implements port.EventPublisher. It never blocks: a subscriber whose
// buffer is full is dropped rather than allowed to stall the relay.
func (h *EventHub) Publish(event port.OutboxEvent) {
message := EventMessage{
Type: event.EventType,
MailboxID: event.AggregateID.String(),
Payload: json.RawMessage(event.Payload),
}
h.mu.RLock()
defer h.mu.RUnlock()
for sub := range h.subscribers {
if sub.mailboxID != message.MailboxID {
continue
}
select {
case sub.events <- message:
default:
// Dropped on purpose; the client resynchronises when it reconnects.
logDroppedSubscriber(sub.mailboxID)
}
}
}
func (h *EventHub) add(sub *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
h.subscribers[sub] = struct{}{}
}
func (h *EventHub) remove(sub *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.subscribers, sub)
close(sub.events)
}
// SubscriberCount is used by tests and by the health surface.
func (h *EventHub) SubscriberCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.subscribers)
}
// ServeHTTP upgrades an authenticated request to a WebSocket.
func (h *EventHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// The session is checked before the upgrade: an unauthenticated caller
// gets a plain 401 and never reaches the WebSocket machinery.
token := ""
if cookie, err := r.Cookie("lm_user_session"); err == nil {
token = cookie.Value
}
if token == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "User session required")
return
}
session, err := h.sessions.RequireSurface(token, "user")
if err != nil {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "User session required")
return
}
mailboxID, err := h.mailboxes.MailboxIDFor(r.Context(), session.Email)
if err != nil {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "User session required")
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// The browser reaches this through the webmail's own origin via the
// Next proxy, so cross-origin upgrades have no legitimate caller.
OriginPatterns: []string{"*"},
})
if err != nil {
return
}
defer conn.CloseNow()
sub := &subscriber{mailboxID: mailboxID, events: make(chan EventMessage, subscriberBuffer)}
h.add(sub)
defer h.remove(sub)
ctx := conn.CloseRead(r.Context())
// Pings keep an idle connection alive through proxies that would otherwise
// close it, and detect a peer that vanished without a FIN.
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case event, ok := <-sub.events:
if !ok {
return
}
writeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
err := wsjson.Write(writeCtx, conn, event)
cancel()
if err != nil {
return
}
case <-ping.C:
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pingCtx)
cancel()
if err != nil {
return
}
}
}
}
// SetEventHub registers the real-time endpoint.
func (r *Router) SetEventHub(hub *EventHub) {
r.events = hub
}
func (r *Router) handleEvents(w http.ResponseWriter, req *http.Request) {
if r.events == nil {
writeError(w, http.StatusServiceUnavailable, "EVENTS_DISABLED",
"The event stream needs JWT_SECRET to verify webmail sessions")
return
}
r.events.ServeHTTP(w, req)
}
var _ port.EventPublisher = (*EventHub)(nil)
// logDroppedSubscriber records that a connection could not keep up. It is a
// signal worth having: a client that drops events repeatedly is one whose
// inbox will look stale until it reconnects.
func logDroppedSubscriber(mailboxID string) {
log.Printf("events: dropping an event for a slow subscriber on mailbox %s", mailboxID)
}
@@ -26,6 +26,9 @@ type Router struct {
// secret is configured, and the routes then answer 503 rather than
// pretending to be open.
mail *mailAPI
// events pushes new mail to the browser. Like mail, it stays nil without a
// session secret.
events *EventHub
// degradedFunc reports a condition that leaves the service running but
// not fit for production, the clearest case being a self-signed
// certificate standing in for one Traefik never issued
@@ -67,6 +70,7 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover)
r.mux.HandleFunc("/api/v1/events", r.handleEvents)
r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders)
r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages)
r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage)