SignalR Integration
The web application uses SignalR to receive real-time push notifications from the backend. When new emails arrive, existing emails are updated, or delivery statuses change, the server pushes events to connected clients through a WebSocket connection. This eliminates the need for polling and provides instant UI updates.
Screenshot
[Screenshot placeholder: Real-time update]
TODO: Add screenshot showing a new email appearing in the inbox in real time
Connection Architecture
SignalRConnection Singleton (src/api/signalr.ts)
The SignalR client is implemented as a singleton class exported as signalRConnection. This ensures that only one WebSocket connection exists regardless of how many components subscribe to events.
import { signalRConnection } from '@/api/signalr'Key design decisions:
- Singleton pattern -- A single connection is shared across all routes and components. The connection is established once and reused.
- Automatic reconnection -- The connection is built with
.withAutomaticReconnect(), which uses SignalR's default retry policy (0s, 2s, 10s, 30s delays) to handle temporary network interruptions. - Credentials included --
withCredentials: trueensures cookies and auth headers are sent with the WebSocket handshake. - Connection deduplication -- If
connect()is called while a connection attempt is already in progress, it returns the existing promise rather than creating a duplicate connection.
Connection Lifecycle
connect(apiUrl) ─── createConnection() ─── HubConnectionBuilder
.withUrl('/hubs/email')
.withAutomaticReconnect()
.build()
.start()The connection transitions through these states:
- Disconnected -- Initial state, or after
disconnect()is called - Connecting --
connect()has been called, WebSocket handshake in progress - Connected -- Active and receiving events
- Reconnecting -- Connection lost, automatic retry in progress
- Reconnected -- Successfully restored after a temporary disconnection
When the connection closes (server shutdown, network failure beyond retry policy), the internal promise is cleared so the next connect() call starts fresh.
Events
The server pushes five event types through the /hubs/email hub:
NewEmail
Fired when a new email is received by the SMTP server and stored in the database.
{
id: string // Email ID
from: string // Sender address
fromDisplay?: string // Sender display name
subject: string // Email subject
receivedAt: string // ISO 8601 timestamp
hasAttachments: boolean
}EmailUpdated
Fired when an email's metadata changes (e.g., marked as read/unread).
{
id: string // Email ID
isRead: boolean // New read state
}EmailDeleted
Fired when an email is permanently deleted.
emailId: string // The ID of the deleted emailUnreadCountChanged
Fired when the total unread email count changes. This can be triggered by new mail arriving, reading an email, or bulk operations.
count: number // New total unread countDeliveryStatusChanged
Fired when an outbound email's delivery status transitions (e.g., Queued to Sending, Sending to Sent).
{
id: string // Outbound email ID
status: string // New status: 'Queued' | 'Sending' | 'Sent' | 'Failed' | 'PartialFailure'
}Event Subscription API
Each event has a corresponding subscription method on the signalRConnection singleton. Each method returns an unsubscribe function:
const unsubscribe = signalRConnection.onNewEmail((email) => {
console.log('New email from:', email.from)
})
// Later, to stop listening:
unsubscribe()Available methods:
| Method | Event |
|---|---|
onNewEmail(handler) | NewEmail |
onEmailUpdated(handler) | EmailUpdated |
onEmailDeleted(handler) | EmailDeleted |
onUnreadCountChanged(handler) | UnreadCountChanged |
onDeliveryStatusChanged(handler) | DeliveryStatusChanged |
If a handler is registered before the connection is established, a warning is logged and a no-op unsubscribe function is returned.
Integration with the Inbox Route
The inbox route (src/routes/index.tsx) is the primary consumer of SignalR events. Here is how the integration works:
On Mount
- The route calls
signalRConnection.connect(apiUrl)to establish (or reuse) the WebSocket connection - Event listeners are registered for all five event types
- Each listener's unsubscribe function is stored for cleanup
Event Handling
NewEmailandEmailDeleted-- Invalidate the['emails']query key, causing TanStack Query to refetch the inbox listEmailUpdated-- Invalidate both the specific['email', id]and the['emails']list queriesUnreadCountChanged-- Updates the unread count directly in local state (Jotai atom) for instant badge updates without waiting for a network round-tripDeliveryStatusChanged-- Invalidates the['outbound']query key to refresh the outbox view
On Unmount
The route unsubscribes from all event handlers but does not disconnect the singleton. This means:
- Navigating away from the inbox stops processing events but keeps the WebSocket open
- Returning to the inbox re-registers handlers on the existing connection
- The connection is only torn down when the browser tab closes or the application unmounts entirely
This approach avoids the overhead of repeatedly establishing and tearing down WebSocket connections during normal navigation.