This document outlines the WebSocket server implementation for the API, which provides real-time push notifications for all IMAP, CalDAV, and CardDAV operations, as well as global app update events.
- Architecture
- Enriched Payloads
- Supported Events
- Security
- Authentication
- Client Integration
- Troubleshooting
The WebSocket server is integrated into the main API server, listening for HTTP upgrade requests on the /v1/ws path. Authentication is optional and, when provided, happens during the upgrade handshake before a connection is established. Unauthenticated clients are accepted and receive only global broadcast events (e.g. newRelease), while authenticated clients receive both per-alias events and broadcast events.
The system supports two notification types:
- Per-Alias Notifications: When a state-changing operation occurs for a specific alias (e.g., a new email arrives, a calendar event is updated), the responsible handler calls
sendWebSocketNotification. This function publishes amsgpackr-encoded message to a dedicated Redis pub/sub channel, scoped to a specificaliasId. - Broadcast Notifications: A background poller periodically checks for new releases of the Forward Email Mail App. If a new release is found (or an existing one is updated), the
ApiWebSocketHandlerbroadcasts anewReleaseevent to all connected clients.
A subscriber on each API server instance listens to the Redis channel and forwards the notification to the appropriate WebSocket clients based on the delivery mode (per-alias or broadcast).
To detect updates to an existing release (e.g., when a GitHub Actions workflow adds compiled assets after initial publication), the poller computes and stores a content fingerprint of the release in Redis. This fingerprint is a SHA-256 hash of the tag name, body content, and a sorted list of asset names and sizes. Any change to these properties will result in a new fingerprint, triggering a newRelease broadcast even if the tag name remains the same.
Asset Gating: When a new release is detected but has no assets yet, the broadcast is deferred. The poller stores the tag as "pending" and waits. Once assets appear (detected by a change in the fingerprint on a subsequent poll), the pending flag is cleared and the newRelease event is broadcast. This ensures clients are only notified when downloadable artifacts are actually available.
graph TD
subgraph "Client-Side Action / Timed Poller"
A[IMAP, CalDAV, or CardDAV Operation]
P[GitHub Release Poller]
end
subgraph "Server-Side Handler"
A --> B["Operation Handler e.g., `on-append.js`"];
B --> C["sendWebSocketNotification(aliasId, event, data)"];
C --> D["encoder.pack({ aliasId, payload })"];
D --> E["redis.publishBuffer(channel, packed_message)"];
P --> Q["checkForNewMailAppRelease() → fingerprint + asset gating"];
Q --> R["_broadcast(payload)"];
R --> S["encoder.pack({ broadcast: true, payload })"];
S --> E;
end
subgraph "Redis Pub/Sub"
E -- "`WS_REDIS_CHANNEL_NAME`" --> F([msgpackr-encoded Buffer]);
end
subgraph "API Server (ApiWebSocketHandler)"
F --> G["subscriber.on('messageBuffer')"];
G --> H["decoder.unpack(message)"];
H -- "broadcast: true" --> I_ALL["Broadcast to ALL clients"];
H -- "has aliasId" --> I_ALIAS["Find clients for `aliasId`"];
I_ALIAS --> J["For each client..."];
I_ALL --> J;
J -- "`?msgpackr=true`" --> K["Send Binary Frame (msgpackr)"];
J -- "default" --> L["Send Text Frame (JSON)"];
end
subgraph "Connected Clients"
K --> M["Webmail / Mobile App / etc."];
L --> M;
end
style A fill:#f9f,stroke:#333,stroke-width:2px
style P fill:#f9f,stroke:#333,stroke-width:2px
style M fill:#ccf,stroke:#333,stroke-width:2px
All internal communication uses msgpackr for efficiency. The client determines the final delivery format via a query parameter, allowing for flexibility.
| Connection URL | Delivery Format | Use Case |
|---|---|---|
wss://api.forwardemail.net/v1/ws |
JSON text frames | Browser clients, easy debugging |
wss://api.forwardemail.net/v1/ws?msgpackr=true |
msgpackr binary frames | Native apps, performance-sensitive clients |
To prevent clients from needing to make follow-up HTTP requests, notification payloads include the full resource object, mirroring the REST API responses.
- IMAP message events include the full raw email in an
emlfield. - CalDAV events include the full iCalendar data in an
icalfield. - CardDAV events include the full vCard data in a
contentfield. - App release events include a
releaseobject with details from the GitHub Release.
Example newMessage Payload:
{
"event": "newMessage",
"timestamp": 1739347200000,
"mailbox": "67abcdef1234567890abcdef",
"message": {
"id": "67abcdef1234567890abcdef",
"uid": 42,
"subject": "Hello World",
"size": 1234,
"eml": "From: sender@example.com\r\nTo: recipient@example.com\r\n..."
}
}Example newRelease Payload:
{
"event": "newRelease",
"timestamp": 1739348200000,
"release": {
"tagName": "v1.2.3",
"name": "Release v1.2.3",
"body": "This release includes several bug fixes and performance improvements.",
"htmlUrl": "https://github.com/forwardemail/mail.forwardemail.net/releases/tag/v1.2.3",
"prerelease": false,
"publishedAt": "2026-02-15T12:00:00Z",
"author": {
"login": "user",
"avatarUrl": "https://github.com/avatars/user.png",
"htmlUrl": "https://github.com/user"
},
"assets": [
{
"name": "mail.forwardemail.net-1.2.3.dmg",
"size": 104857600,
"downloadCount": 500,
"browserDownloadUrl": "https://github.com/forwardemail/mail.forwardemail.net/releases/download/v1.2.3/mail.forwardemail.net-1.2.3.dmg"
}
]
}
}The implementation covers 20 distinct event types across three protocols and one global event type.
| Event | Trigger | Key Payload Fields |
|---|---|---|
newMessage |
APPEND / SMTP delivery |
mailbox, message (with eml) |
messagesMoved |
MOVE |
sourceMailbox, destinationMailbox, sourceUid, destinationUid |
messagesCopied |
COPY |
sourceMailbox, destinationMailbox, sourceUid, destinationUid |
flagsUpdated |
STORE / implicit \Seen |
mailbox, action, flags, uid |
messagesExpunged |
EXPUNGE |
mailbox, uids |
mailboxCreated |
CREATE |
path, mailbox |
mailboxDeleted |
DELETE |
path, mailbox |
mailboxRenamed |
RENAME |
oldPath, newPath, mailbox |
Notifications are sent for all Created, Updated, and Deleted operations on calendars, calendar events, address books, and contacts.
| Event | Trigger | Key Payload Fields |
|---|---|---|
newRelease |
A new version of the Forward Email Mail App is published, or an existing release is updated (e.g. assets added). | release |
Security is a primary design consideration, addressed through multiple layers:
- Optional Authentication: Authentication is optional. Authenticated clients receive both per-alias events and global broadcast events. Unauthenticated clients are also accepted but only receive global broadcast events (e.g.
newRelease). Both API Token (?alias_id=required) and Alias Password auth are supported for authenticated connections. - Read-Only Channel: The connection is strictly for server-to-client push. Any data messages received from a client are silently ignored.
- Strict Channel Isolation: For authenticated clients, the server maps connections to a specific
alias_id. A client will only ever receive notifications for the alias it is subscribed to. Global events likenewReleaseare broadcast to all clients (both authenticated and unauthenticated). - Rate Limiting & Connection Caps: To prevent abuse, the server enforces a per-IP connection rate limit (30/minute), a per-alias connection limit for authenticated clients (10), a per-IP limit for unauthenticated clients (3), and a global connection limit (10,000).
- Keep-Alive: A 30-second ping/pong keep-alive mechanism terminates unresponsive or stale connections.
Authentication is optional for WebSocket connections. The authentication method determines what events you receive:
- Authenticated connections: Receive both per-alias events (IMAP, CalDAV, CardDAV) and global broadcast events (
newRelease) - Unauthenticated connections: Receive only global broadcast events (
newRelease)
Authentication is provided via HTTP Basic Authentication. The server supports the Authorization header as the preferred method, and also accepts query parameters (?username=, ?password=, ?token=) as a fallback for browser WebSocket clients that cannot set custom headers on the upgrade request. There are two supported authentication methods:
Use your alias email address and generated password for authentication.
Browser Example (query parameters):
Browser WebSocket does not support custom headers. Use query parameters:
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws?username=user@domain.com&password=alias-password");Alternatively, embed credentials in the URL userinfo (the browser translates this into an Authorization header):
const ws = new WebSocket("wss://user%40domain.com:alias-password@api.forwardemail.net/v1/ws");Node.js Example (Authorization header):
const WebSocket = require("ws");
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws", {
headers: {
Authorization: `Basic ${Buffer.from("user@domain.com:alias-password").toString("base64")}`
}
});Requirements:
- Username: Your alias email address (e.g.,
user@domain.com) - Password: Your generated alias password
- The domain must have
has_smtp: trueenabled - The alias must exist and have tokens configured
Use your API token for authentication. This method requires the alias_id query parameter to specify which alias to subscribe to.
Browser Example (query parameters):
Browser WebSocket does not support custom headers. Use the ?token= query parameter:
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws?token=YOUR_API_TOKEN&alias_id=YOUR_ALIAS_ID");Node.js Example (Authorization header):
const WebSocket = require("ws");
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws?alias_id=YOUR_ALIAS_ID", {
headers: {
Authorization: `Basic ${Buffer.from("YOUR_API_TOKEN:").toString("base64")}`
}
});Requirements:
- Username: Your API token
- Password: Empty string
- Query parameter:
?alias_id=<your-alias-id>(required) - The alias must belong to your user account or you must be a domain admin/member
Connect without any authentication to receive only global broadcast events.
Browser Example:
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws");Node.js Example:
const WebSocket = require("ws");
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws");Upon successful connection, the server sends a connected event indicating the authentication status:
Authenticated Connection:
{
"event": "connected",
"aliasId": "67abcdef1234567890abcdef"
}This confirms you are authenticated and will receive both per-alias events for the specified alias and global broadcast events.
Unauthenticated Connection:
{
"event": "connected",
"broadcastOnly": true
}This confirms you are connected but will only receive global broadcast events (e.g., newRelease). You will not receive per-alias notifications.
- Query parameter authentication is supported: The server accepts
?username=+?password=and?token=(with?alias_id=) as a fallback for browser WebSocket clients that cannot set custom headers. TheAuthorizationheader is preferred when available (e.g. Node.js clients). - Failed authentication: If credentials are provided but invalid, the server responds with a
401 Unauthorizederror. It does NOT fall back to broadcast-only mode — only connections with no credentials at all are treated as unauthenticated. - msgpackr encoding: Add
?msgpackr=trueto the connection URL to receive binary msgpackr-encoded frames instead of JSON text frames for reduced bandwidth.
Authenticated (Alias Password — query parameters):
// Browser WebSocket does not support custom headers.
// Use query parameters for authentication:
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws?username=user@domain.com&password=alias-password");
ws.onopen = () => {
console.log("WebSocket connection established");
};
ws.onmessage = (event) => {
const notification = JSON.parse(event.data);
console.log("Received event:", notification.event, notification);
// Check connection status
if (notification.event === "connected") {
if (notification.aliasId) {
console.log("Authenticated! Alias ID:", notification.aliasId);
} else if (notification.broadcastOnly) {
console.log("Connected in broadcast-only mode (unauthenticated)");
}
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
ws.onclose = (event) => {
console.log("WebSocket closed:", event.code, event.reason);
};Unauthenticated (Broadcast-Only):
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws");
ws.onmessage = (event) => {
const notification = JSON.parse(event.data);
console.log("Received event:", notification.event, notification);
};Authenticated (Alias Password):
const WebSocket = require("ws");
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws", {
headers: {
Authorization: `Basic ${Buffer.from("user@domain.com:alias-password").toString("base64")}`
}
});
ws.on("open", () => {
console.log("WebSocket connection established");
});
ws.on("message", (data) => {
const notification = JSON.parse(data.toString());
console.log("Received event:", notification.event, notification);
// Check connection status
if (notification.event === "connected") {
if (notification.aliasId) {
console.log("Authenticated! Alias ID:", notification.aliasId);
} else if (notification.broadcastOnly) {
console.log("Connected in broadcast-only mode (unauthenticated)");
}
}
});
ws.on("error", (error) => {
console.error("WebSocket error:", error);
});
ws.on("close", (code, reason) => {
console.log("WebSocket closed:", code, reason.toString());
});Authenticated (API Token):
const WebSocket = require("ws");
const aliasId = "YOUR_ALIAS_ID";
const apiToken = "YOUR_API_TOKEN";
const ws = new WebSocket(`wss://api.forwardemail.net/v1/ws?alias_id=${aliasId}`, {
headers: {
Authorization: `Basic ${Buffer.from(`${apiToken}:`).toString("base64")}`
}
});
ws.on("message", (data) => {
const notification = JSON.parse(data.toString());
console.log("Received event:", notification.event, notification);
});For high-performance applications, use msgpackr encoding to reduce bandwidth:
const WebSocket = require("ws");
const { Decoder } = require("msgpackr");
const decoder = new Decoder();
const ws = new WebSocket("wss://api.forwardemail.net/v1/ws?msgpackr=true", {
headers: {
Authorization: `Basic ${Buffer.from("user@domain.com:alias-password").toString("base64")}`
}
});
ws.on("message", (data, isBinary) => {
const notification = isBinary ? decoder.unpack(data) : JSON.parse(data);
console.log("Received event:", notification.event, notification);
});Possible causes:
- Invalid credentials: Your email/password or API token is incorrect
- Missing
alias_idparameter: When using API token authentication, you must include?alias_id=<your-alias-id> - Domain not enabled: The domain must have
has_smtp: trueenabled - Alias not configured: The alias must exist and have tokens configured
Solution:
- Verify your credentials are correct
- For API token auth, ensure you include
?alias_id=<your-alias-id>in the URL - Use either the
Authorizationheader or query parameters (?username=,?password=,?token=) for authentication - Check that your domain and alias are properly configured
Possible causes:
- Invalid API token: Your API token is incorrect or expired
- Invalid alias credentials: Your alias email or password is incorrect
- Missing
alias_idfor token auth: API token authentication requires?alias_id=<your-alias-id>
Solution:
- Verify your credentials are correct
- For API token auth, include
?alias_id=<your-alias-id>in the URL - Use either the
Authorizationheader or query parameters for authentication
Possible causes:
- Connected in broadcast-only mode: Check if you received
broadcastOnly: truein theconnectedevent - Authentication failed silently: Your credentials may be invalid
- Wrong alias: You may be authenticated to a different alias than expected
Solution:
- Check the
connectedevent response foraliasIdorbroadcastOnly - If you see
broadcastOnly: true, your authentication failed - Verify you're using the correct credentials and authentication method
Possible causes:
- Rate limiting: You've exceeded 30 connections per minute from your IP
- Connection limit reached: You've exceeded 10 concurrent connections per alias (authenticated) or 3 per IP (unauthenticated)
- Global limit reached: The server has reached 10,000 concurrent connections
Solution:
- Wait before attempting to reconnect
- Close unused connections
- Implement exponential backoff for reconnection attempts
Possible causes:
- No events are being generated: The events you're expecting may not be occurring
- Connected to wrong alias: You may be authenticated to a different alias
- Broadcast-only mode: You're only receiving global broadcast events
Solution:
- Verify events are being generated (e.g., send a test email)
- Check the
connectedevent to confirm youraliasId - Ensure you're authenticated if you expect per-alias events