Push INBOX unread counts over NATS/WebSocket instead of polling

FeatureEmailService
Shipped
August 6, 2026 at 11:58 AM UTC
Author
Kamo
Commit
145fb64

The nav-top mail badge in kamo-internal refreshed itself by calling /api/email/folders every 30 seconds, per browser tab. That endpoint walks the entire folder tree and opens every folder read-only to answer, so the cost of one integer was a full mailbox summary, repeated indefinitely. The WebSocket that already existed only announced that mail had arrived and carried no count, so its one job was to trigger another poll. Publish the count itself: - EmailUnreadSnapshot on NATS email.unread.<addr>, relayed to STOMP /topic/email/unread/<memberId>. NATS is in the path because the pod that observes a change is routinely not the pod holding the member's WebSocket. - EmailUnreadPublisher coalesces per mailbox, so a ten-message IDLE delivery or a page of mail marked read is one IMAP round trip and one publish, off the caller's thread. - MailReader.getUnreadCount reads one folder. Graph answers it from the folder list it already returns; IMAP-backed providers override it with a single STATUS (UNSEEN). - A snapshot is published on subscribe, on IDLE messagesAdded/messagesRemoved, on IMAP flag changes, and from the mark-read / flags / move / delete / spam endpoints. Three gaps this also closes: - Flag changes were invisible. Reading mail in Roundcube or on a phone sets \Seen without adding or removing a message, so the badge only ever fell when the browser next polled. IDLE now carries a MessageChangedListener. - Cloud mailboxes got no push at all. EmailWebSocketController resolved credentials through the KamoMail virtual_users table, which has no row for a member on Microsoft 365 or Google Workspace, so subscribe() logged a warning and returned. It now resolves through MailProviderResolver, and IDLE connects with the credential spec (host/port/TLS/XOAUTH2) via a shared ImapStoreFactory.connect rather than assuming the KamoMail host and a password. - Sessions leaked. A closed tab never sends /email/unsubscribe, so mailboxes are now reference counted per STOMP session and released on SessionDisconnectEvent. Mailboxes with no live IDLE listener — Graph, past the 50-connection cap, or a dead IDLE thread — are swept server-side every 60s and published to the same topic. That replaces per-tab polling rather than push: one sweep serves every tab a member has open, membership is re-tested each pass so a dead listener is picked up, and the browser still receives the count over the WebSocket. MessageIndexer binds email.> and deserialized everything on it as a message, so it now skips subjects outside email.inbox. — filtered in the handler rather than by narrowing the subscription, because email-metadata-indexer is a durable consumer and changing its filter subject would reject the subscribe on every pod until the durable was deleted by hand.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing