diff --git a/README.md b/README.md index b557ffb..f264e42 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ To go back, run `rustmail restore-backup` with the new binary **before** install |---|---| | **Persistent storage** | SQLite-backed, emails survive restarts. `--ephemeral` for CI. | | **Full-text search** | FTS5 across subject, body, sender, and recipients. | -| **Real-time UI** | WebSocket push: new email appears instantly. Dark/light mode, keyboard shortcuts. | +| **Real-time UI** | WebSocket push: new email appears instantly. Dark/light mode, five colour palettes, keyboard shortcuts. | | **CI-native** | REST assertion endpoints, CLI assert mode, and a first-party GitHub Action. | -| **Single binary** | Frontend embedded at compile time. ~7 MB, zero runtime dependencies. | +| **Single binary** | Frontend embedded at compile time. 10 to 14 MB depending on the target, zero runtime dependencies. | | **Auth header display** | Parses DKIM, SPF, DMARC, and ARC headers with color-coded status badges. | | **Webhooks** | Fire-and-forget POST on every new message. | | **Email release** | Forward captured emails to a real SMTP server. | diff --git a/docs/ci-integration/cli-assert.md b/docs/ci-integration/cli-assert.md index 69fb6f8..b163470 100644 --- a/docs/ci-integration/cli-assert.md +++ b/docs/ci-integration/cli-assert.md @@ -18,6 +18,8 @@ rustmail assert [OPTIONS] | `--sender` | — | Filter by sender address substring | | `--recipient` | — | Filter by recipient address substring | | `--smtp-port` | `1025` | SMTP port to listen on | +| `--max-message-size` | `10485760` | Maximum accepted message size in bytes | +| `--log-level` | `info` | Log verbosity: `trace`, `debug`, `info`, `warn`, `error` | ## Examples diff --git a/docs/ci-integration/github-action.md b/docs/ci-integration/github-action.md index ecae672..1b33c8c 100644 --- a/docs/ci-integration/github-action.md +++ b/docs/ci-integration/github-action.md @@ -33,7 +33,7 @@ The action has two modes: | `mode` | `start` | Set to `start` (or omit, it's the default) | | `smtp-port` | `1025` | SMTP port to listen on | | `http-port` | `8025` | HTTP/API port | -| `version` | `latest` | RustMail version (e.g., `v0.1.0`) | +| `version` | `latest` | RustMail version (e.g., `v0.8.1`) | ### Assert Mode @@ -117,7 +117,7 @@ By default the action downloads the latest RustMail release. Pin to a specific v ```yaml - uses: rustmailapp/rustmail-action@v1 with: - version: v0.2.1 # pin to a release tag + version: v0.8.1 # pin to a release tag ``` Check [GitHub Releases](https://github.com/rustmailapp/rustmail/releases) for available versions. diff --git a/docs/ci-integration/rest-assertions.md b/docs/ci-integration/rest-assertions.md index b7c03e0..db6c081 100644 --- a/docs/ci-integration/rest-assertions.md +++ b/docs/ci-integration/rest-assertions.md @@ -12,8 +12,8 @@ GET /api/v1/assert/count | Parameter | Type | Description | |-----------|------|-------------| -| `min` | integer | Minimum number of matching messages (inclusive) | -| `max` | integer | Maximum number of matching messages (inclusive) | +| `min` | integer | Minimum number of matching messages (inclusive). Defaults to `1`. | +| `max` | integer | Maximum number of matching messages (inclusive). No upper limit when omitted. | | `subject` | string | Filter by subject substring (case-insensitive) | | `sender` | string | Filter by sender address substring | | `recipient` | string | Filter by recipient address substring | @@ -41,7 +41,7 @@ curl -f "localhost:8025/api/v1/assert/count?min=1&recipient=admin@example.com" { "ok": true, "count": 2 } // 417 Expectation Failed: assertion failed -{ "ok": false, "count": 0, "expected_min": 1, "expected_max": null } +{ "ok": false, "count": 0, "expected_min": 1, "expected_max": 9223372036854775807 } ``` ## In a CI Script diff --git a/docs/configuration/toml-config.md b/docs/configuration/toml-config.md index 903c3b2..27169f0 100644 --- a/docs/configuration/toml-config.md +++ b/docs/configuration/toml-config.md @@ -24,6 +24,7 @@ webhook_url = "https://hooks.example.com/email" release_host = "smtp.example.com:587" allowed_origins = ["https://mail.example.com"] allowed_hosts = ["mail.example.com"] +ws_buffer = 4096 ``` Configure both `smtp_tls_cert` and `smtp_tls_key` to enable optional SMTP `STARTTLS` on the existing SMTP listener. Setting only one of the two fails startup. diff --git a/docs/features/keyboard-shortcuts.md b/docs/features/keyboard-shortcuts.md index be634ee..82d723c 100644 --- a/docs/features/keyboard-shortcuts.md +++ b/docs/features/keyboard-shortcuts.md @@ -30,6 +30,11 @@ asks for confirmation instead, because clearing the inbox cannot be undone. within it. Individual rows are not tab stops, so the list stays navigable no matter how many rows are on screen. +While the list has keyboard focus, the selected row carries a rounded accent +outline; with no message selected, the outline goes around the whole list. +Clicking a row selects it without the outline, so the tinted background alone +marks the selection when you use the mouse. + | Key | Action | |-----|--------| | `↓` | Select next message | diff --git a/docs/features/release.md b/docs/features/release.md index 3bbb2a6..0a27c13 100644 --- a/docs/features/release.md +++ b/docs/features/release.md @@ -49,7 +49,7 @@ Release is locked down to prevent misuse: 2. **Host allowlist**: the `host` in the request body must exactly match the configured `--release-host` host. Mismatches return `403`. 3. **Port allowlist**: only standard SMTP ports are accepted: `25`, `465`, `587`, `2525`. Other ports return `400`. 4. **Port pinning**: if `--release-host` includes a port (e.g., `smtp.example.com:587`), the request must use that exact port. Mismatches return `403`. -5. **TLS required**: connections use `lettre::relay()` with certificate verification. TLS failures return `502`. +5. **TLS required**: port `465` uses implicit TLS; ports `25`, `587` and `2525` must upgrade with `STARTTLS`, and the message is not sent if the relay cannot. Certificates are verified. TLS failures return `502`. ## Errors diff --git a/docs/features/web-ui.md b/docs/features/web-ui.md index ac02e49..02df439 100644 --- a/docs/features/web-ui.md +++ b/docs/features/web-ui.md @@ -22,7 +22,15 @@ Below 1300px the details rail becomes a drawer over the message body. Open it wi The gear icon in the header opens **Settings**. Under **Appearance**: - **Theme**: **Dark**, **Light**, or **System** (the default, which follows the operating system). -- **Palette**: **RustMail** (the default), **Ember**, **Copper & Teal**, **Dawn**, or **Classic**. Each palette tints the neutrals and the accent colour, in both themes. +- **Palette**: sets the tint of the neutrals, the accent colour, the glow along the top of the window and the selected row, in both themes. + +| Palette | Look | +|---------|------| +| **RustMail** (default) | Warm iron neutrals, an oxide orange accent, and an amber-to-oxide glow and selection | +| **Ember** | Cool slate neutrals with an orange accent | +| **Copper & Teal** | Teal-grey neutrals, an orange accent, and a copper and teal glow | +| **Dawn** | Rose-grey neutrals, a coral accent, and an amber-to-rose glow and selection | +| **Classic** | Plain zinc neutrals, an orange accent, and a grey selection | Both choices are kept in the browser's local storage, so they apply per browser rather than per server. diff --git a/docs/features/webhooks.md b/docs/features/webhooks.md index c97888b..e4e05b3 100644 --- a/docs/features/webhooks.md +++ b/docs/features/webhooks.md @@ -28,14 +28,14 @@ Each webhook is an HTTP POST with `Content-Type: application/json`. The body is { "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "sender": "user@example.com", - "recipients": "[\"recipient@example.com\"]", + "recipients": ["recipient@example.com"], "subject": "Hello World", "size": 1024, "has_attachments": false, "is_read": false, "is_starred": false, "tags": [], - "created_at": "2026-03-23T10:30:45.123Z" + "created_at": "2026-03-23T10:30:45Z" } ``` @@ -43,14 +43,14 @@ Each webhook is an HTTP POST with `Content-Type: application/json`. The body is |-------|------|-------------| | `id` | string | ULID: use this to fetch the full message via the REST API | | `sender` | string | Envelope sender address | -| `recipients` | string | JSON-encoded array of recipient addresses | +| `recipients` | array of strings | Envelope recipient addresses | | `subject` | string \| null | Parsed subject line | | `size` | integer | Raw message size in bytes | | `has_attachments` | boolean | Whether the message has attachments | | `is_read` | boolean | Always `false` for new messages | | `is_starred` | boolean | Always `false` for new messages | | `tags` | array | Always `[]` for new messages | -| `created_at` | string | ISO 8601 UTC timestamp | +| `created_at` | string | ISO 8601 UTC timestamp, second precision | ## Behavior @@ -58,13 +58,15 @@ Each webhook is an HTTP POST with `Content-Type: application/json`. The body is - **5-second timeout**: if the endpoint doesn't respond within 5 seconds, the request is abandoned. - **No retries**: failed deliveries are logged as warnings and not retried. - **No queue**: webhooks are sent in real time as messages arrive. If the endpoint is down, those notifications are lost. +- **10 at a time**: at most 10 deliveries are in flight at once; further ones wait for a free slot. +- **No local or private IP literals**: RustMail refuses to start if the URL's host is `localhost` or a loopback, private, link-local or other reserved IP address. Host names are not resolved, so a name that points at such an address is accepted. ## Example: Slack Notification Pair with a lightweight relay that transforms the payload into a Slack message: ```sh -rustmail serve --webhook-url http://localhost:3000/relay +rustmail serve --webhook-url https://relay.example.com/rustmail ``` The relay receives the JSON payload above and can POST a formatted message to Slack's Incoming Webhooks API. diff --git a/docs/features/websocket.md b/docs/features/websocket.md index 12b3491..07b3086 100644 --- a/docs/features/websocket.md +++ b/docs/features/websocket.md @@ -8,7 +8,7 @@ RustMail pushes real-time events over a WebSocket connection. The UI uses this f ws://localhost:8025/api/v1/ws ``` -The connection is **one-way push**: the server sends events to the client. Messages sent by the client are ignored. +The connection is **one-way push**: the server sends events to the client. Messages sent by the client carry no meaning, but any frame from the client counts as a sign of life (see [Keepalive](#keepalive)). ## Events @@ -28,12 +28,14 @@ Fired when a new email is received and stored. "data": { "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "sender": "user@example.com", - "recipients": "[\"recipient@example.com\"]", + "recipients": ["recipient@example.com"], "subject": "Hello World", "size": 1024, "has_attachments": false, "is_read": false, - "created_at": "2026-03-23T10:30:45.123Z" + "is_starred": false, + "tags": [], + "created_at": "2026-03-23T10:30:45Z" } } ``` @@ -113,9 +115,15 @@ rustmail serve --allowed-origin https://mail.example.com A maximum of **50 concurrent WebSocket connections** is enforced. New connections beyond this limit receive `503 Service Unavailable`. +## Keepalive + +The server sends a ping when the connection opens and every 30 seconds after that. Browsers answer with a pong on their own. If no frame arrives from the client for 90 seconds, the server closes the connection. A send that blocks for more than 10 seconds closes it too, so a client that stopped reading cannot hold one of the 50 slots. + +A client that falls more than [`--ws-buffer`](/configuration/cli-flags) events behind is disconnected instead of being sent the events that are left. Its view of the inbox is already incomplete at that point, so it should reconnect and fetch the list again. + ## Reconnection -The built-in UI reconnects automatically with exponential backoff (2s initial, 30s cap), resetting on successful connection. If you're building a custom client, implement similar retry logic. +The built-in UI reconnects automatically with exponential backoff (2s initial, 30s cap) and random jitter, resetting on successful connection. If you're building a custom client, implement similar retry logic. ## Example: Node.js Client diff --git a/docs/getting-started/architecture.md b/docs/getting-started/architecture.md index 787d592..109c108 100644 --- a/docs/getting-started/architecture.md +++ b/docs/getting-started/architecture.md @@ -6,9 +6,9 @@ RustMail is a Cargo workspace with five crates, each with a single responsibilit | Crate | Responsibility | |-------|---------------| -| `rustmail-smtp` | TCP listener, ESMTP handshake, emits parsed messages over a tokio broadcast channel | +| `rustmail-smtp` | TCP listener, ESMTP handshake, hands each message to the server over a tokio mpsc channel and acknowledges it once stored | | `rustmail-storage` | sqlx + SQLite repository, FTS5 index, retention enforcement | -| `rustmail-api` | Axum routes, WebSocket broadcast, bridges HTTP to storage and SMTP channel | +| `rustmail-api` | Axum routes and the WebSocket; bridges HTTP to storage and relays released mail | | `rustmail-server` | Binary entry point: parses config, wires all crates, embeds UI assets | | `rustmail-tui` | Terminal UI client (optional, connects to a running RustMail instance) | @@ -19,7 +19,7 @@ SMTP Client (your app) │ ▼ rustmail-smtp - │ tokio broadcast channel (ReceivedMessage) + │ tokio mpsc channel (Delivery) ▼ rustmail-storage │ event broadcast @@ -44,4 +44,4 @@ rustmail-api The UI is a SolidJS + TypeScript + Tailwind CSS v4 application in the `ui/` directory. At build time, Vite produces static assets that are embedded into the Rust binary via `rust-embed`. The server binary serves these at `/` with SPA fallback routing. -Build output: ~28 KB JS + ~13 KB CSS (gzipped). +Build output: ~48 KB JS + ~11 KB CSS (gzipped). diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md index 8ac7345..8afdeeb 100644 --- a/docs/getting-started/introduction.md +++ b/docs/getting-started/introduction.md @@ -14,7 +14,7 @@ Most alternatives are either unmaintained (MailHog), minimal (MailCrab), or clou - **Search that works**: FTS5 across subject, body, sender, and recipients. - **Real-time UI**: WebSocket push, dark mode, keyboard shortcuts. Feels like a proper email client. - **CI-first**: REST assertion endpoints, a CLI assert mode, and a GitHub Action. -- **Zero runtime deps**: one binary, frontend baked in. Also ships as a ~8 MB Docker image. +- **Zero runtime deps**: one binary, frontend baked in. Also ships as a ~10 MB Docker image. ## How It Works @@ -24,7 +24,7 @@ SMTP Client (your app) ▼ rustmail-smtp ─── tokio TCP listener, ESMTP handshake │ - ▼ broadcast channel + ▼ mpsc channel rustmail-storage ─── sqlx + SQLite + FTS5 │ ▼ event broadcast diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index a6bcd5d..45ab536 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -11,7 +11,8 @@ docker run -p 1025:1025 -p 8025:8025 smyile/rustmail:latest ### Binary ```sh -# Default: SMTP on 1025, UI on 8025, SQLite at ./rustmail.db +# Default: SMTP on 1025, UI on 8025, SQLite in the user data directory +# (see --db-path in the configuration reference) rustmail # Ephemeral mode for CI: in-memory, nothing written to disk diff --git a/docs/public/screenshot-web.png b/docs/public/screenshot-web.png index 7b1db8e..0c70914 100644 Binary files a/docs/public/screenshot-web.png and b/docs/public/screenshot-web.png differ