#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:
1 parent
3e60852551
commit
93d21b4752
12 files changed
+643
No files matched your search
@@ -0,0 +1,30 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OutboxEvent is one row of domain_events_outbox.
|
||||
type OutboxEvent struct {
|
||||
ID int64
|
||||
EventType string
|
||||
AggregateID uuid.UUID
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// OutboxRepository reads and retires the transactional outbox.
|
||||
type OutboxRepository interface {
|
||||
// FetchUnpublished returns the oldest unpublished events, oldest first, so
|
||||
// a subscriber sees them in the order they happened.
|
||||
FetchUnpublished(ctx context.Context, limit int) ([]OutboxEvent, error)
|
||||
MarkPublished(ctx context.Context, ids []int64) error
|
||||
}
|
||||
|
||||
// EventPublisher fans a domain event out to whoever is listening. It must not
|
||||
// block: the relay holds no lock, but a slow subscriber would still stall the
|
||||
// batch behind it.
|
||||
type EventPublisher interface {
|
||||
Publish(event OutboxEvent)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// OutboxRelay publishes domain events recorded inside the delivery transaction
|
||||
// (PLAN.md section 9.4).
|
||||
//
|
||||
// Events are read from the table rather than emitted in-process because that
|
||||
// is the whole point of the outbox: 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 - the row is still
|
||||
// there on restart.
|
||||
type OutboxRelay struct {
|
||||
repo port.OutboxRepository
|
||||
publisher port.EventPublisher
|
||||
batchSize int
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func NewOutboxRelay(repo port.OutboxRepository, publisher port.EventPublisher) *OutboxRelay {
|
||||
return &OutboxRelay{repo: repo, publisher: publisher, batchSize: 100, interval: time.Second}
|
||||
}
|
||||
|
||||
// Run polls until the context is cancelled.
|
||||
func (r *OutboxRelay) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(r.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := r.PublishBatch(ctx); err != nil {
|
||||
log.Printf("outbox relay: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PublishBatch drains one batch and reports how many events were published.
|
||||
func (r *OutboxRelay) PublishBatch(ctx context.Context) (int, error) {
|
||||
events, err := r.repo.FetchUnpublished(ctx, r.batchSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(events))
|
||||
for _, event := range events {
|
||||
// Delivery is best effort: a subscriber that is not connected simply
|
||||
// misses the push and reconciles on its next reconnect. Holding the
|
||||
// row back for an absent listener would stall every other event.
|
||||
r.publisher.Publish(event)
|
||||
ids = append(ids, event.ID)
|
||||
}
|
||||
|
||||
if err := r.repo.MarkPublished(ctx, ids); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(ids), nil
|
||||
}
|
||||
@@ -46,6 +46,12 @@ func NewWebmailUseCase(
|
||||
return &WebmailUseCase{repo: repo, blobs: blobs, submission: submission, auth: auth, localHost: localHost}
|
||||
}
|
||||
|
||||
// MailboxIDFor resolves a session's address to its mailbox, which the event
|
||||
// stream needs so it can filter pushes per account.
|
||||
func (uc *WebmailUseCase) MailboxIDFor(ctx context.Context, address string) (string, error) {
|
||||
return uc.mailboxID(ctx, address)
|
||||
}
|
||||
|
||||
func (uc *WebmailUseCase) mailboxID(ctx context.Context, address string) (string, error) {
|
||||
id, err := uc.repo.FindMailboxIDByAddress(ctx, address)
|
||||
if err != nil || id == "" {
|
||||
|
||||
Reference in new issue
Block a user