v0.2.22
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-04 14:59:03 +01:00
1 parent 6e74b33f03
commit 3b9110c8c8
44 files changed
+3040 -111

No files matched your search

+15
View File
@@ -1,5 +1,20 @@
# RustBox changelog # RustBox changelog
### 0.2.22
- Added message snooze. A new `snoozed_messages` table (migration `V052`) records a wake time for a message; snoozing it (via the new "Snooze…" context-menu entry or the `b` shortcut while a message is open) hides it client-side from its folder's current view until that time, using `GET /folders/{name}/snoozed-uids` to know which UIDs are currently asleep. `POST /messages/{folder}/{uid}/snooze` and `DELETE /messages/{folder}/{uid}/snooze` set/clear a snooze; `GET /messages/snoozed` lists everything currently sleeping, shown in the new "View snoozed messages…" dialog (`Shift+B`). This is purely a local cache: it has no IMAP side effects and does not change server-side folder pagination/count queries.
- Added an "Undo Send" window. New `undo_send_window_secs` config setting (default `8`, max `120`; `0` restores the previous immediate-send behavior). When enabled, submitting a message stages it in memory instead of sending it right away; the composer shows a toast with an "Undo" action and countdown, and the message is only dispatched to SMTP once the window elapses without being cancelled. `GET /messages/send/{pending_send_id}` polls status (`pending`/`dispatching`/`sent`/`cancelled`/`failed`); `DELETE /messages/send/{pending_send_id}` cancels a still-pending send. Nothing is persisted, so a server restart during the window drops the staged send the same way an in-flight SMTP connection would be lost.
- Bumped the application/source release to 0.2.22.
### 0.2.21
- Added configurable login brute-force protection under `[login_rate_limit]` (env: `LOGIN_RATE_LIMIT__*`): the per-IP token bucket on `POST /api/auth/login` is now driven by `ip_burst`/`ip_replenish_secs` instead of hard-coded values, plus failed-attempt lockouts per mailbox (`account_max_failures`, any IP) and per client IP (`ip_max_failures`, password spraying) with exponential back-off (`lockout_secs` → `max_lockout_secs`). Locked attempts return `429` with `Retry-After` before any IMAP connection is opened.
- The public `GET /api/auth/policy` no longer consumes the per-IP login budget.
- Added `cookie_secure = "auto" | "always" | "never"` (env: `COOKIE_SECURE`). The default `auto` sets the `Secure` cookie attribute only when the browser actually reached RustBox over HTTPS (trusted-proxy `X-Forwarded-Proto`/`Forwarded`/`X-Forwarded-Ssl`, otherwise the `Origin`/`Referer` scheme). Previously `Secure` depended on `environment != "development"`, which broke logins on plain-HTTP production installs and left cookies without `Secure` in development behind HTTPS.
- Added memory guards for messages with attachments. An attachment set that cannot fit `max_message_size_mb` once base64-encoded is now rejected with `413` from the stored sizes before any blob is read when sending, saving an IMAP draft snapshot or attaching a message, and while an upload is still streaming (previously the limit was only checked after the whole MIME message had been built in RAM). New `max_concurrent_message_builds` (default 4) and `message_build_wait_secs` (default 30) limit how many requests hold attachment data in memory at once; extra requests wait and then receive `503`. Environment: `MAX_CONCURRENT_MESSAGE_BUILDS`, `MESSAGE_BUILD_WAIT_SECS`.
- Gmail-style message row opening: outside selection mode, Ctrl/Cmd+click a message opens it in a new browser tab, and Shift+click opens it in a small popup window; both deep-link to the exact message so the new tab/window loads it directly. Plain click and existing selection-mode shift/ctrl range/toggle behavior are unchanged.
- Bumped the application/source release to 0.2.21.
### 0.2.20 ### 0.2.20
- Fixed interrupted initial-backfill recovery so legacy missing-date repair does not run against an invalidated or incomplete folder cache. - Fixed interrupted initial-backfill recovery so legacy missing-date repair does not run against an invalidated or incomplete folder cache.
Generated
+1 -1
View File
@@ -3506,7 +3506,7 @@ dependencies = [
[[package]] [[package]]
name = "rustbox" name = "rustbox"
version = "0.2.20" version = "0.2.22"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-imap", "async-imap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustbox" name = "rustbox"
version = "0.2.20" version = "0.2.22"
edition = "2024" edition = "2024"
rust-version = "1.99.1" rust-version = "1.99.1"
default-run = "rustbox" default-run = "rustbox"
+1 -1
View File
@@ -1,6 +1,6 @@
# RustBox attribution / notice # RustBox attribution / notice
RustBox 0.2.20 is derived from the Oxi webmail project: RustBox 0.2.22 is derived from the Oxi webmail project:
- Upstream: `c0h1b4/oxi` - Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+16 -1
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.20 # RustBox 0.2.22
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
@@ -59,6 +59,10 @@ Administrator-only deep runtime diagnostics can be enabled separately under `[di
Mail endpoints are configured by the administrator, not on the login screen. Users enter only email and password. Mail endpoints are configured by the administrator, not on the login screen. Users enter only email and password.
Login brute-force protection is on by default and configured in the `[login_rate_limit]` section (or `LOGIN_RATE_LIMIT__*` environment variables): a per-IP token bucket on the login endpoint plus failed-attempt lockouts per mailbox and per client IP with exponential back-off. Locked attempts are answered with `429` and `Retry-After` without contacting the IMAP server. Counters are in memory per node. See `config.example.toml` for every option.
Session cookies get the `Secure` attribute according to `cookie_secure` (default `auto`): only when the browser reached RustBox over HTTPS, detected from a trusted proxy's `X-Forwarded-Proto`/`Forwarded`/`X-Forwarded-Ssl` header or the browser's `Origin`/`Referer`. Plain-HTTP installations keep working; set `cookie_secure = "always"` behind an HTTPS proxy that does not forward the scheme.
Edit `config.dev.toml` before the first run: Edit `config.dev.toml` before the first run:
```toml ```toml
@@ -77,6 +81,15 @@ tls_enabled = true
max_attachment_size_mb = 10 max_attachment_size_mb = 10
# Hard limit for the complete rendered RFC822/MIME message submitted to the MTA. # Hard limit for the complete rendered RFC822/MIME message submitted to the MTA.
max_message_size_mb = 50 max_message_size_mb = 50
# Memory guard for messages with attachments. Sending a message or saving an IMAP
# draft snapshot loads every attachment into RAM and the MIME builder keeps several
# copies (about 3-4x the message size). Attachment sets that cannot fit
# max_message_size_mb are rejected with 413 before any file is read, and at most
# max_concurrent_message_builds requests may hold attachments in memory at once;
# the others wait up to message_build_wait_secs and then get 503.
# Rough peak RAM = max_concurrent_message_builds x 4 x max_message_size_mb.
max_concurrent_message_builds = 4
message_build_wait_secs = 30
# Optional features exposed by the server/UI. # Optional features exposed by the server/UI.
sieve_enabled = true sieve_enabled = true
@@ -102,6 +115,8 @@ If the mail host settings are omitted, RustBox uses `localhost` with IMAP `993`
Mail-size limits are intentionally separate. `max_attachment_size_mb` applies to each raw uploaded file, while `max_message_size_mb` is checked against the final RFC822/MIME byte stream immediately before SMTP submission. The latter therefore includes message bodies, headers, MIME boundaries, base64 expansion, inline CID parts and OpenPGP transformations. The composer displays only an estimate; the server-side final-MIME check is authoritative. Mail-size limits are intentionally separate. `max_attachment_size_mb` applies to each raw uploaded file, while `max_message_size_mb` is checked against the final RFC822/MIME byte stream immediately before SMTP submission. The latter therefore includes message bodies, headers, MIME boundaries, base64 expansion, inline CID parts and OpenPGP transformations. The composer displays only an estimate; the server-side final-MIME check is authoritative.
Attachments are streamed to blob storage on upload, but sending a message or saving an IMAP draft snapshot has to hold them in memory while the MIME message is built. RustBox therefore (1) rejects an attachment set whose base64-encoded size cannot fit `max_message_size_mb` with `413` before any file is read into memory, and also while an upload is still streaming, and (2) allows only `max_concurrent_message_builds` such operations at the same time; further requests wait up to `message_build_wait_secs` and then receive `503`. Peak RAM for these operations is roughly `max_concurrent_message_builds` x 4 x `max_message_size_mb`. Fully streamed MIME assembly is not implemented.
## Database, storage, search and cluster traffic ## Database, storage, search and cluster traffic
`database_backend = "sqlite" | "mysql" | "pgsql"` selects only the authoritative SQL database. It does not force or forbid local blob/search storage. In particular, PostgreSQL/MySQL are valid in a single-node installation together with `storage.backend="filesystem"` and `search.backend="local_tantivy"`. `database_backend = "sqlite" | "mysql" | "pgsql"` selects only the authoritative SQL database. It does not force or forbid local blob/search storage. In particular, PostgreSQL/MySQL are valid in a single-node installation together with `storage.backend="filesystem"` and `search.backend="local_tantivy"`.
+16 -5
View File
@@ -1,6 +1,16 @@
RustBox 0.2.20 release manifest RustBox 0.2.22 release manifest
0.2.20 release changes and retained feature set: 0.2.22 release changes:
- adds message snooze: snoozing a message hides it client-side from its folder's current view until the chosen wake time, without touching server-side folder pagination/count SQL; `snoozed_messages` is a local-only cache table with no IMAP side effects, so a snoozed message is unaffected on any other client
- adds an "Undo Send" window: when `undo_send_window_secs > 0` (default 8s, max 120s), a submitted message is held in memory and only dispatched to SMTP after the window elapses unless cancelled; nothing is persisted, so a server restart during the window loses the staged send the same way an in-flight SMTP connection would be lost
- adds `GET/DELETE /messages/send/{pending_send_id}` to poll or cancel a staged send, and `POST/DELETE /messages/{folder}/{uid}/snooze`, `GET /messages/snoozed`, `GET /folders/{name}/snoozed-uids` for snooze
0.2.21 release changes and retained feature set:
- adds configurable login brute-force protection (`[login_rate_limit]`): per-IP token bucket plus per-mailbox and per-IP failed-attempt lockouts with exponential back-off; locked attempts return 429 + Retry-After before any IMAP connection
- the public `/api/auth/policy` GET no longer consumes the per-IP login budget
- adds `cookie_secure = auto | always | never`; `auto` sets the cookie `Secure` attribute only for requests that really arrived over HTTPS, so plain-HTTP installs keep working
- adds attachment memory guards: attachment sets that cannot fit `max_message_size_mb` once MIME-encoded are rejected with 413 from stored sizes before any file is loaded (send, IMAP draft snapshot, attach-message, streaming upload), and `max_concurrent_message_builds` / `message_build_wait_secs` bound how many requests hold attachments in RAM at once
- adds gmail-style message row opening: Ctrl/Cmd+click opens a message in a new tab, Shift+click opens it in a popup window, both deep-linked (outside selection mode; existing shift/ctrl selection behavior while selecting is unchanged)
- fixes the Axum route visibility mismatch for `AttachMessageSourceRequest`, allowing the forward-as-attachment handler to compile from `routes::mod` - fixes the Axum route visibility mismatch for `AttachMessageSourceRequest`, allowing the forward-as-attachment handler to compile from `routes::mod`
- falls back to IMAP `INTERNALDATE` for messages with a missing/invalid message `Date` and repairs cached missing dates when opening a folder - falls back to IMAP `INTERNALDATE` for messages with a missing/invalid message `Date` and repairs cached missing dates when opening a folder
- forwards a message as a real server-created `message/rfc822` `.eml` draft attachment - forwards a message as a real server-created `message/rfc822` `.eml` draft attachment
@@ -64,7 +74,8 @@ Database/schema:
- compatibility baseline is V050; former V047-V050 changes are consolidated into `schema.sql` and their standalone migrations are removed - compatibility baseline is V050; former V047-V050 changes are consolidated into `schema.sql` and their standalone migrations are removed
- databases older than V050 must be upgraded with a V050-capable RustBox release before using this build - databases older than V050 must be upgraded with a V050-capable RustBox release before using this build
- V051 adds `display_preferences.adaptive_email_colors` and `mail_preferences.message_loading_mode` to existing V050 tenants - V051 adds `display_preferences.adaptive_email_colors` and `mail_preferences.message_loading_mode` to existing V050 tenants
- next migration number: V052 - V052 adds `snoozed_messages` (local-only cache state, no IMAP side effects) to existing V051 tenants
- next migration number: V053
Docker examples: Docker examples:
- docker/compose-single-sqlite.yml - docker/compose-single-sqlite.yml
@@ -74,14 +85,14 @@ Docker examples:
Operations/docs: Operations/docs:
- standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets - standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets
- README.md documents 0.2.20 configuration - README.md documents 0.2.22 configuration
- docker/README.md documents Compose variants - docker/README.md documents Compose variants
- docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior
- TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending
Build/release metadata: Build/release metadata:
- `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts
- Cargo.toml version = 0.2.20 - Cargo.toml version = 0.2.22
- rust-toolchain.toml pins Rust 1.99.1 - rust-toolchain.toml pins Rust 1.99.1
- Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing - Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing
- CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build - CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build
+16 -6
View File
@@ -1,6 +1,16 @@
RustBox 0.2.20 - final source validation RustBox 0.2.22 - final source validation
0.2.20 scope: 0.2.22 scope:
- adds message snooze (`snoozed_messages`, V052): hides a message client-side from its current folder view until a chosen wake time; purely client-side hiding, no server-side pagination/count SQL changes, no IMAP side effects
- adds an in-memory "Undo Send" window (`undo_send_window_secs`, default 8s, max 120s): a submitted message is staged and only dispatched to SMTP once the window elapses unless cancelled first; not persisted, so a restart during the window drops the staged send
- new endpoints: `GET/DELETE /messages/send/{pending_send_id}`, `POST/DELETE /messages/{folder}/{uid}/snooze`, `GET /messages/snoozed`, `GET /folders/{name}/snoozed-uids`
0.2.21 scope:
- adds configurable login brute-force protection (`[login_rate_limit]`): per-IP token bucket plus per-mailbox and per-IP failed-attempt lockouts with exponential back-off; locked attempts return 429 + Retry-After before any IMAP connection
- the public `/api/auth/policy` GET no longer consumes the per-IP login budget
- adds `cookie_secure = auto | always | never`; `auto` sets the cookie `Secure` attribute only for requests that really arrived over HTTPS, so plain-HTTP installs keep working
- adds attachment memory guards: attachment sets that cannot fit `max_message_size_mb` once MIME-encoded are rejected with 413 from stored sizes before any file is loaded (send, IMAP draft snapshot, attach-message, streaming upload), and `max_concurrent_message_builds` / `message_build_wait_secs` bound how many requests hold attachments in RAM at once
- adds gmail-style message row opening: Ctrl/Cmd+click opens a message in a new tab, Shift+click opens it in a popup window, both deep-linked (outside selection mode; existing shift/ctrl selection behavior while selecting is unchanged)
- fixes the Axum route visibility mismatch for `AttachMessageSourceRequest`, allowing the forward-as-attachment handler to compile from `routes::mod` - fixes the Axum route visibility mismatch for `AttachMessageSourceRequest`, allowing the forward-as-attachment handler to compile from `routes::mod`
- falls back to IMAP `INTERNALDATE` for messages with a missing/invalid message `Date` and repairs cached missing dates when opening a folder - falls back to IMAP `INTERNALDATE` for messages with a missing/invalid message `Date` and repairs cached missing dates when opening a folder
- forwards a message as a real server-created `message/rfc822` `.eml` draft attachment - forwards a message as a real server-created `message/rfc822` `.eml` draft attachment
@@ -35,16 +45,16 @@ RustBox 0.2.20 - final source validation
- PDF attachment preview uses the authenticated inline URL for Safari/iPhone compatibility - PDF attachment preview uses the authenticated inline URL for Safari/iPhone compatibility
- Composing shows only compact attachment/message size-limit values on one line - Composing shows only compact attachment/message size-limit values on one line
- 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact - 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact
- V047-V050 are consolidated into compatibility baseline V050; V051 migrates existing tenants with adaptive HTML-mail colors and mailbox loading mode; V052 is next - V047-V050 are consolidated into compatibility baseline V050; V051 migrates existing tenants with adaptive HTML-mail colors and mailbox loading mode; V052 adds `snoozed_messages` to existing V051 tenants; V053 is next
- version metadata identifies 0.2.20 - version metadata identifies 0.2.22
Completed in this environment: Completed in this environment:
- offline release validator: scripts/validate-release-static.py (421/421 checks passed) - offline release validator: scripts/validate-release-static.py (424/424 checks passed)
- browser JavaScript assembled in manifest order passes `node --check` - browser JavaScript assembled in manifest order passes `node --check`
- all JSON files parse successfully - all JSON files parse successfully
- Python helper scripts compile successfully - Python helper scripts compile successfully
- shell scripts pass `bash -n` - shell scripts pass `bash -n`
- release metadata consistently identifies 0.2.20 - release metadata consistently identifies 0.2.22
- V050 baseline consolidation plus the V051 adaptive-colors/mailbox-loading migration are validated offline - V050 baseline consolidation plus the V051 adaptive-colors/mailbox-loading migration are validated offline
+13
View File
@@ -2,6 +2,7 @@
host = "127.0.0.1" host = "127.0.0.1"
port = 3001 port = 3001
environment = "development" environment = "development"
cookie_secure = "auto"
# Global light/dark theme: system | dark | light # Global light/dark theme: system | dark | light
default_theme = "system" default_theme = "system"
# Global interface style used by the login screen and by accounts set to # Global interface style used by the login screen and by accounts set to
@@ -38,6 +39,18 @@ tls_enabled = true
# Per-file raw upload limit and final RFC822/MIME MTA limit (MiB). # Per-file raw upload limit and final RFC822/MIME MTA limit (MiB).
max_attachment_size_mb = 10 max_attachment_size_mb = 10
max_message_size_mb = 50 max_message_size_mb = 50
# Memory guard for messages with attachments. Sending a message or saving an IMAP
# draft snapshot loads every attachment into RAM and the MIME builder keeps several
# copies (about 3-4x the message size). Attachment sets that cannot fit
# max_message_size_mb are rejected with 413 before any file is read, and at most
# max_concurrent_message_builds requests may hold attachments in memory at once;
# the others wait up to message_build_wait_secs and then get 503.
# Rough peak RAM = max_concurrent_message_builds x 4 x max_message_size_mb.
max_concurrent_message_builds = 4
message_build_wait_secs = 30
# "Undo Send" window in seconds (0 disables; max 120). See config.example.toml.
undo_send_window_secs = 8
# Optional administrator-facing features. # Optional administrator-facing features.
sieve_enabled = true sieve_enabled = true
+52
View File
@@ -2,6 +2,12 @@
host = "127.0.0.1" host = "127.0.0.1"
port = 3001 port = 3001
environment = "production" environment = "production"
# Secure attribute of session cookies: auto | always | never.
# auto (default) sets Secure only when the browser reached RustBox over HTTPS:
# X-Forwarded-Proto / Forwarded: proto= / X-Forwarded-Ssl from a trusted proxy,
# otherwise the scheme of the browser's Origin/Referer. Plain-HTTP installations
# therefore keep working. Use "always" behind HTTPS if your proxy strips headers.
cookie_secure = "auto"
# Global light/dark theme: system | dark | light # Global light/dark theme: system | dark | light
default_theme = "system" default_theme = "system"
# Global interface style used by the login screen and by accounts set to # Global interface style used by the login screen and by accounts set to
@@ -51,6 +57,22 @@ tls_enabled = true
max_attachment_size_mb = 10 max_attachment_size_mb = 10
# Maximum size of the complete RFC822/MIME message submitted to the MTA (MiB). # Maximum size of the complete RFC822/MIME message submitted to the MTA (MiB).
max_message_size_mb = 50 max_message_size_mb = 50
# Memory guard for messages with attachments. Sending a message or saving an IMAP
# draft snapshot loads every attachment into RAM and the MIME builder keeps several
# copies (about 3-4x the message size). Attachment sets that cannot fit
# max_message_size_mb are rejected with 413 before any file is read, and at most
# max_concurrent_message_builds requests may hold attachments in memory at once;
# the others wait up to message_build_wait_secs and then get 503.
# Rough peak RAM = max_concurrent_message_builds x 4 x max_message_size_mb.
max_concurrent_message_builds = 4
message_build_wait_secs = 30
# "Undo Send" window in seconds. A submitted message is held in memory (not
# persisted) for this long before it is actually dispatched to SMTP, so the
# sender can cancel it. 0 disables the feature and sends immediately. Must be
# <= 120; keep this short, since a server restart during the window loses the
# staged send.
undo_send_window_secs = 8
# Optional reverse-proxy subpath, e.g. "/mail". # Optional reverse-proxy subpath, e.g. "/mail".
# base_path = "/mail" # base_path = "/mail"
@@ -97,6 +119,36 @@ pgp_max_key_bytes = 1048576
# Private-key export is deliberately disabled unless the administrator opts in. # Private-key export is deliberately disabled unless the administrator opts in.
pgp_allow_private_export = false pgp_allow_private_export = false
# Login brute-force protection (POST /api/auth/login). Enabled by default.
# Environment overrides use a double underscore, e.g.
# LOGIN_RATE_LIMIT__ACCOUNT_MAX_FAILURES=10 or LOGIN_RATE_LIMIT__ENABLED=false.
# Client IPs are taken from X-Forwarded-For only when the peer is in trusted_proxies.
# State is kept in memory on each node: behind a load balancer every node counts
# separately, so use sticky routing or lower thresholds in cluster mode.
[login_rate_limit]
enabled = true
# Per-IP token bucket: up to ip_burst attempts at once, then one new attempt
# every ip_replenish_secs seconds. Requests over the limit get HTTP 429.
ip_burst = 5
ip_replenish_secs = 12
# Failed logins counted inside failure_window_secs before a lockout.
# account_max_failures: per mailbox (case-insensitive), from any IP.
# ip_max_failures: per client IP, across all mailboxes (password spraying).
# 0 disables that lockout type.
account_max_failures = 5
ip_max_failures = 20
failure_window_secs = 900
# First lockout length; every consecutive lockout of the same key doubles it
# up to max_lockout_secs. Locked attempts are refused before contacting IMAP.
# Trade-off: an attacker can keep a known mailbox locked for webmail logins
# (IMAP/SMTP clients are unaffected), so keep max_lockout_secs moderate.
lockout_secs = 300
max_lockout_secs = 3600
# IPs/CIDRs exempt from the per-IP lockout (account lockouts still apply).
exempt_networks = []
# Memory cap per tracking table (accounts, IPs).
max_tracked_keys = 100000
[storage] [storage]
backend = "filesystem" # filesystem | s3 backend = "filesystem" # filesystem | s3
# path = "/var/lib/rustbox/blobs" # defaults to data_dir for filesystem # path = "/var/lib/rustbox/blobs" # defaults to data_dir for filesystem
+16 -2
View File
@@ -1,7 +1,21 @@
# RustBox 0.2.20 - implementation status # RustBox 0.2.22 - implementation status
- Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification. - Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification.
This file tracks the `0.2.20` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. This file tracks the `0.2.22` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly.
## 0.2.22 snooze and undo send
- Adds message snooze: `snoozed_messages` (migration V052) is a local-only cache table — snoozing a message records a wake time and the client hides that message from its current folder view until then, via `GET /folders/{name}/snoozed-uids`; it has no IMAP side effects and does not touch server-side folder pagination/count SQL in `routes/messages/mod.rs`. `POST/DELETE /messages/{folder}/{uid}/snooze` set/clear a snooze, and `GET /messages/snoozed` lists everything currently sleeping across folders for the "View snoozed messages…" dialog. UI: a "Snooze…" context-menu entry and the `b` shortcut (message open) prompt for a duration; `Shift+B` opens the snoozed-messages list.
- Adds an "Undo Send" window: a new `undo_send_window_secs` config knob (default 8, max 120 seconds; 0 disables it and restores immediate synchronous send). When enabled, a submitted message is staged in an in-memory `UndoSendRegistry` (`src/undo_send.rs`) instead of being dispatched immediately; the HTTP response returns right away with `status: "pending"`, and a delayed task actually sends the message once the window elapses unless cancelled first. `GET /messages/send/{pending_send_id}` polls status (`pending`/`dispatching`/`sent`/`cancelled`/`failed`); `DELETE /messages/send/{pending_send_id}` cancels a still-pending send. Nothing is persisted: a server restart during the window loses the staged send, the same as an in-flight SMTP connection would be lost. The composer shows a toast with an "Undo" action and live countdown for the window.
- Known limitation: a brand-new, never-autosaved compose that is sent and then left un-undone relies on the existing autosave/draft-recovery mechanism for recovery if the dispatch itself fails; this is not a new guarantee beyond what autosave already provides.
## 0.2.21 login hardening
- Adds configurable login brute-force protection (`[login_rate_limit]`): per-IP token bucket plus per-mailbox and per-IP failed-attempt lockouts with exponential back-off; locked attempts return 429 + Retry-After before any IMAP connection
- The public `/api/auth/policy` GET no longer consumes the per-IP login budget
- Adds `cookie_secure = auto | always | never`; `auto` sets the cookie `Secure` attribute only for requests that really arrived over HTTPS, so plain-HTTP installs keep working
- Adds attachment memory guards: attachment sets that cannot fit `max_message_size_mb` once MIME-encoded are rejected with 413 from stored sizes before any file is loaded (send, IMAP draft snapshot, attach-message, streaming upload), and `max_concurrent_message_builds` / `message_build_wait_secs` bound how many requests hold attachments in RAM at once
- Adds Gmail-style message row opening: Ctrl/Cmd+click opens a message in a new tab, Shift+click opens it in a popup window, both deep-linked (outside selection mode; existing shift/ctrl selection behavior while selecting is unchanged)
## 0.2.20 browser mail UX ## 0.2.20 browser mail UX
+7 -1
View File
@@ -23,7 +23,13 @@ V051 adds the preferences introduced in 0.2.16:
Fresh installations already contain the V051 columns in `schema.sql` and are registered at the latest schema version. Existing V050 tenants execute `V051__adaptive_email_colors_and_message_loading_mode.sql` before application queries use the new columns. Fresh installations already contain the V051 columns in `schema.sql` and are registered at the latest schema version. Existing V050 tenants execute `V051__adaptive_email_colors_and_message_loading_mode.sql` before application queries use the new columns.
The next migration number is **V052**. ## V052 — RustBox 0.2.22
V052 adds the `snoozed_messages` table used by the Snooze feature introduced in 0.2.22: a message can be hidden from its folder listing in the client until a chosen wake time, without being moved on the real IMAP server. The table is local cache state only (no IMAP side effects) and has no foreign keys, matching `message_tags`' existing exemption from forward-referencing foreign keys in the MySQL/PostgreSQL schemas.
Fresh installations already contain `snoozed_messages` in `schema.sql` and are registered at the latest schema version. Existing V051 tenants execute `V052__snooze.sql`.
The next migration number is **V053**.
## Migration rules ## Migration rules
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE {{p}}snoozed_messages (
id VARCHAR(64) PRIMARY KEY,
message_uid BIGINT NOT NULL,
message_folder VARCHAR(512) NOT NULL,
wake_at BIGINT NOT NULL,
created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)),
UNIQUE KEY idx_snoozed_messages_unique (message_uid, message_folder),
INDEX idx_snoozed_messages_wake (wake_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+11 -1
View File
@@ -1,7 +1,7 @@
-- RustBox 0.2.0 MySQL schema. -- RustBox 0.2.0 MySQL schema.
-- One physical database contains global sessions plus isolated per-account tables. -- One physical database contains global sessions plus isolated per-account tables.
-- Application tables are materialized by replacing {{p}} with a collision-checked prefix. -- Application tables are materialized by replacing {{p}} with a collision-checked prefix.
-- Squashed compatibility baseline: V050; current schema: V051. -- Squashed compatibility baseline: V050; current schema: V052.
-- RUSTBOX:GLOBAL-BEGIN -- RUSTBOX:GLOBAL-BEGIN
CREATE TABLE IF NOT EXISTS rustbox_accounts ( CREATE TABLE IF NOT EXISTS rustbox_accounts (
@@ -406,6 +406,16 @@ CREATE TABLE {{p}}saved_searches (
updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)) updated_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE {{p}}snoozed_messages (
id VARCHAR(64) PRIMARY KEY,
message_uid BIGINT NOT NULL,
message_folder VARCHAR(512) NOT NULL,
wake_at BIGINT NOT NULL,
created_at VARCHAR(64) NOT NULL DEFAULT (CAST(CURRENT_TIMESTAMP AS CHAR)),
UNIQUE KEY idx_snoozed_messages_unique (message_uid, message_folder),
INDEX idx_snoozed_messages_wake (wake_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE {{p}}special_folders ( CREATE TABLE {{p}}special_folders (
role VARCHAR(255) PRIMARY KEY, role VARCHAR(255) PRIMARY KEY,
folder_name TEXT NOT NULL, folder_name TEXT NOT NULL,
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE {{p}}snoozed_messages (
id TEXT PRIMARY KEY,
message_uid BIGINT NOT NULL,
message_folder TEXT NOT NULL,
wake_at BIGINT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
UNIQUE (message_uid, message_folder)
);
CREATE INDEX {{p}}idx_snoozed_messages_wake ON {{p}}snoozed_messages(wake_at);
+12 -1
View File
@@ -1,7 +1,7 @@
-- RustBox 0.2.0 PostgreSQL schema. -- RustBox 0.2.0 PostgreSQL schema.
-- One physical database contains global sessions plus isolated per-account tables. -- One physical database contains global sessions plus isolated per-account tables.
-- Application tables are materialized by replacing {{p}} with a collision-checked prefix. -- Application tables are materialized by replacing {{p}} with a collision-checked prefix.
-- Squashed compatibility baseline: V050; current schema: V051. -- Squashed compatibility baseline: V050; current schema: V052.
-- RUSTBOX:GLOBAL-BEGIN -- RUSTBOX:GLOBAL-BEGIN
CREATE TABLE IF NOT EXISTS rustbox_accounts ( CREATE TABLE IF NOT EXISTS rustbox_accounts (
@@ -408,6 +408,15 @@ CREATE TABLE {{p}}saved_searches (
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text) updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
); );
CREATE TABLE {{p}}snoozed_messages (
id TEXT PRIMARY KEY,
message_uid BIGINT NOT NULL,
message_folder TEXT NOT NULL,
wake_at BIGINT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
UNIQUE (message_uid, message_folder)
);
CREATE TABLE {{p}}special_folders ( CREATE TABLE {{p}}special_folders (
role TEXT PRIMARY KEY, role TEXT PRIMARY KEY,
folder_name TEXT NOT NULL, folder_name TEXT NOT NULL,
@@ -458,6 +467,8 @@ CREATE INDEX {{p}}idx_messages_in_reply_to ON {{p}}messages (in_reply_to);
CREATE INDEX {{p}}idx_messages_message_id ON {{p}}messages (message_id); CREATE INDEX {{p}}idx_messages_message_id ON {{p}}messages (message_id);
CREATE INDEX {{p}}idx_pgp_keys_email ON {{p}}pgp_keys (lower(email)); CREATE INDEX {{p}}idx_pgp_keys_email ON {{p}}pgp_keys (lower(email));
CREATE UNIQUE INDEX {{p}}idx_saved_searches_name ON {{p}}saved_searches (name); CREATE UNIQUE INDEX {{p}}idx_saved_searches_name ON {{p}}saved_searches (name);
CREATE INDEX {{p}}idx_snoozed_messages_wake ON {{p}}snoozed_messages(wake_at);
CREATE UNIQUE INDEX {{p}}idx_tags_imap_keyword ON {{p}}tags (imap_keyword) WHERE imap_keyword <> ''; CREATE UNIQUE INDEX {{p}}idx_tags_imap_keyword ON {{p}}tags (imap_keyword) WHERE imap_keyword <> '';
-- RUSTBOX:TENANT-END -- RUSTBOX:TENANT-END
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE {{p}}snoozed_messages (
id TEXT PRIMARY KEY,
message_uid INTEGER NOT NULL,
message_folder TEXT NOT NULL,
wake_at INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (message_uid, message_folder)
);
CREATE INDEX {{p}}idx_snoozed_messages_wake ON {{p}}snoozed_messages(wake_at);
+12 -1
View File
@@ -1,7 +1,7 @@
-- RustBox 0.2.0 SQLite schema. -- RustBox 0.2.0 SQLite schema.
-- One physical database contains global sessions plus isolated per-account tables. -- One physical database contains global sessions plus isolated per-account tables.
-- Application tables are materialized from the tenant section by replacing {{p}}. -- Application tables are materialized from the tenant section by replacing {{p}}.
-- Squashed compatibility baseline: V050; current schema: V051. -- Squashed compatibility baseline: V050; current schema: V052.
-- RUSTBOX:GLOBAL-BEGIN -- RUSTBOX:GLOBAL-BEGIN
CREATE TABLE IF NOT EXISTS rustbox_accounts ( CREATE TABLE IF NOT EXISTS rustbox_accounts (
@@ -320,6 +320,15 @@ CREATE TABLE {{p}}saved_searches (
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE {{p}}snoozed_messages (
id TEXT PRIMARY KEY,
message_uid INTEGER NOT NULL,
message_folder TEXT NOT NULL,
wake_at INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (message_uid, message_folder)
);
CREATE TABLE {{p}}special_folders ( CREATE TABLE {{p}}special_folders (
role TEXT PRIMARY KEY CHECK(role IN ('sent','drafts','trash','junk','archive')), role TEXT PRIMARY KEY CHECK(role IN ('sent','drafts','trash','junk','archive')),
folder_name TEXT NOT NULL, folder_name TEXT NOT NULL,
@@ -374,6 +383,8 @@ CREATE INDEX {{p}}idx_pgp_keys_email ON {{p}}pgp_keys(lower(email));
CREATE UNIQUE INDEX {{p}}idx_saved_searches_name ON {{p}}saved_searches(name COLLATE NOCASE); CREATE UNIQUE INDEX {{p}}idx_saved_searches_name ON {{p}}saved_searches(name COLLATE NOCASE);
CREATE INDEX {{p}}idx_snoozed_messages_wake ON {{p}}snoozed_messages(wake_at);
CREATE UNIQUE INDEX {{p}}idx_tags_imap_keyword ON {{p}}tags(imap_keyword) WHERE imap_keyword <> ''; CREATE UNIQUE INDEX {{p}}idx_tags_imap_keyword ON {{p}}tags(imap_keyword) WHERE imap_keyword <> '';
-- RUSTBOX:TENANT-END -- RUSTBOX:TENANT-END
+53
View File
@@ -10,6 +10,12 @@
host = "0.0.0.0" host = "0.0.0.0"
port = 3001 port = 3001
environment = "production" environment = "production"
# Secure attribute of session cookies: auto | always | never.
# auto (default) sets Secure only when the browser reached RustBox over HTTPS:
# X-Forwarded-Proto / Forwarded: proto= / X-Forwarded-Ssl from a trusted proxy,
# otherwise the scheme of the browser's Origin/Referer. Plain-HTTP installations
# therefore keep working. Use "always" behind HTTPS if your proxy strips headers.
cookie_secure = "auto"
data_dir = "/var/lib/rustbox" data_dir = "/var/lib/rustbox"
log_dir = "/var/log/rustbox" log_dir = "/var/log/rustbox"
@@ -127,6 +133,18 @@ max_attachment_size_mb = 50
# Maximum complete RFC822/MIME message size in MiB. # Maximum complete RFC822/MIME message size in MiB.
max_message_size_mb = 100 max_message_size_mb = 100
# Memory guard for messages with attachments. Sending a message or saving an IMAP
# draft snapshot loads every attachment into RAM and the MIME builder keeps several
# copies (about 3-4x the message size). Attachment sets that cannot fit
# max_message_size_mb are rejected with 413 before any file is read, and at most
# max_concurrent_message_builds requests may hold attachments in memory at once;
# the others wait up to message_build_wait_secs and then get 503.
# Rough peak RAM = max_concurrent_message_builds x 4 x max_message_size_mb.
max_concurrent_message_builds = 4
message_build_wait_secs = 30
# "Undo Send" window in seconds (0 disables; max 120). See config.example.toml.
undo_send_window_secs = 8
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -210,6 +228,41 @@ pgp_allow_private_export = true
# #
# pgp_enabled = false # pgp_enabled = false
# -----------------------------------------------------------------------------
# Login brute-force protection
# -----------------------------------------------------------------------------
# Login brute-force protection (POST /api/auth/login). Enabled by default.
# Environment overrides use a double underscore, e.g.
# LOGIN_RATE_LIMIT__ACCOUNT_MAX_FAILURES=10 or LOGIN_RATE_LIMIT__ENABLED=false.
# Client IPs are taken from X-Forwarded-For only when the peer is in trusted_proxies.
# State is kept in memory on each node: behind a load balancer every node counts
# separately, so use sticky routing or lower thresholds in cluster mode.
[login_rate_limit]
enabled = true
# Per-IP token bucket: up to ip_burst attempts at once, then one new attempt
# every ip_replenish_secs seconds. Requests over the limit get HTTP 429.
ip_burst = 5
ip_replenish_secs = 12
# Failed logins counted inside failure_window_secs before a lockout.
# account_max_failures: per mailbox (case-insensitive), from any IP.
# ip_max_failures: per client IP, across all mailboxes (password spraying).
# 0 disables that lockout type.
account_max_failures = 5
ip_max_failures = 20
failure_window_secs = 900
# First lockout length; every consecutive lockout of the same key doubles it
# up to max_lockout_secs. Locked attempts are refused before contacting IMAP.
# Trade-off: an attacker can keep a known mailbox locked for webmail logins
# (IMAP/SMTP clients are unaffected), so keep max_lockout_secs moderate.
lockout_secs = 300
max_lockout_secs = 3600
# IPs/CIDRs exempt from the per-IP lockout (account lockouts still apply).
exempt_networks = []
# Memory cap per tracking table (accounts, IPs).
max_tracked_keys = 100000
[storage] [storage]
# filesystem | s3 # filesystem | s3
+512
View File
@@ -0,0 +1,512 @@
//! Login brute-force protection.
//!
//! The per-IP token bucket (`tower_governor`, configured in `routes::mod`) caps the
//! raw request rate on `POST /api/auth/login`. This module adds failure-based
//! lockouts on top of it:
//!
//! * per account (case-insensitive e-mail, regardless of client IP) — stops slow,
//! distributed password guessing against one mailbox;
//! * per client IP (any account) — stops password spraying from one source.
//!
//! Consecutive lockouts of the same key double the lockout time up to
//! `max_lockout_secs`. A locked request is rejected before RustBox opens an IMAP
//! connection, so brute force does not reach (or trigger lockouts on) the mail
//! server. State is in memory and per node; see `[login_rate_limit]` in the
//! configuration for all knobs.
use std::collections::HashMap;
use std::hash::Hash;
use std::net::IpAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
use ipnet::IpNet;
use crate::config::LoginRateLimitConfig;
/// Expired entries are swept every N recorded failures.
const PRUNE_EVERY: u64 = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockScope {
Account,
Ip,
}
impl LockScope {
pub fn as_str(self) -> &'static str {
match self {
Self::Account => "account",
Self::Ip => "ip",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LoginBlocked {
pub retry_after: Duration,
pub scope: LockScope,
}
impl LoginBlocked {
/// Whole seconds for the `Retry-After` header (rounded up, at least 1).
pub fn retry_after_secs(&self) -> u64 {
let secs = self.retry_after.as_secs();
let secs = if self.retry_after.subsec_nanos() > 0 {
secs.saturating_add(1)
} else {
secs
};
secs.max(1)
}
}
#[derive(Debug, Clone, Copy)]
struct Policy {
failure_window: Duration,
lockout: Duration,
max_lockout: Duration,
max_tracked_keys: usize,
}
impl Policy {
/// Lockout duration for the n-th consecutive lockout (n >= 1).
fn lockout_for(&self, lockouts: u32) -> Duration {
let exponent = lockouts.saturating_sub(1).min(20);
self.lockout
.saturating_mul(1u32 << exponent)
.min(self.max_lockout)
}
/// Idle time after which an unlocked entry (and its back-off history) is dropped.
fn idle_ttl(&self) -> Duration {
self.failure_window.max(self.max_lockout)
}
}
#[derive(Debug, Clone)]
struct FailureEntry {
window_start: Instant,
failures: u32,
lockouts: u32,
locked_until: Option<Instant>,
last_activity: Instant,
}
impl FailureEntry {
fn new(now: Instant) -> Self {
Self {
window_start: now,
failures: 0,
lockouts: 0,
locked_until: None,
last_activity: now,
}
}
fn remaining_lock(&self, now: Instant) -> Option<Duration> {
self.locked_until
.and_then(|until| until.checked_duration_since(now))
.filter(|remaining| !remaining.is_zero())
}
}
struct FailureTracker<K> {
entries: Mutex<HashMap<K, FailureEntry>>,
/// 0 disables this tracker.
max_failures: u32,
}
impl<K: Eq + Hash> FailureTracker<K> {
fn new(max_failures: u32) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
max_failures,
}
}
fn lock(&self) -> MutexGuard<'_, HashMap<K, FailureEntry>> {
self.entries
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn remaining_lock(&self, key: &K, now: Instant) -> Option<Duration> {
if self.max_failures == 0 {
return None;
}
self.lock()
.get(key)
.and_then(|entry| entry.remaining_lock(now))
}
/// Records one failure; returns the new lockout duration if this failure
/// triggered a lockout.
fn record_failure(&self, key: K, now: Instant, policy: &Policy) -> Option<Duration> {
if self.max_failures == 0 {
return None;
}
let mut entries = self.lock();
if !entries.contains_key(&key) && entries.len() >= policy.max_tracked_keys {
Self::prune_locked(&mut entries, now, policy);
if entries.len() >= policy.max_tracked_keys {
tracing::warn!(
tracked = entries.len(),
"login rate limiter table is full; new key not tracked"
);
return None;
}
}
let entry = entries.entry(key).or_insert_with(|| FailureEntry::new(now));
if entry.remaining_lock(now).is_some() {
// Requests that were already in flight when the lock was set.
entry.last_activity = now;
return None;
}
if entry.lockouts > 0 && now.duration_since(entry.last_activity) >= policy.idle_ttl() {
entry.lockouts = 0;
}
if now.duration_since(entry.window_start) >= policy.failure_window {
entry.window_start = now;
entry.failures = 0;
}
entry.failures = entry.failures.saturating_add(1);
entry.last_activity = now;
if entry.failures < self.max_failures {
return None;
}
entry.lockouts = entry.lockouts.saturating_add(1);
let duration = policy.lockout_for(entry.lockouts);
entry.locked_until = Some(now + duration);
entry.failures = 0;
entry.window_start = now;
Some(duration)
}
fn clear(&self, key: &K) {
self.lock().remove(key);
}
fn prune(&self, now: Instant, policy: &Policy) {
Self::prune_locked(&mut self.lock(), now, policy);
}
fn prune_locked(entries: &mut HashMap<K, FailureEntry>, now: Instant, policy: &Policy) {
let ttl = policy.idle_ttl();
entries.retain(|_, entry| {
entry.remaining_lock(now).is_some() || now.duration_since(entry.last_activity) < ttl
});
}
#[cfg(test)]
fn len(&self) -> usize {
self.lock().len()
}
}
pub struct LoginGuard {
enabled: bool,
policy: Policy,
exempt_networks: Vec<IpNet>,
accounts: FailureTracker<String>,
ips: FailureTracker<IpAddr>,
recorded_failures: AtomicU64,
}
impl LoginGuard {
pub fn from_config(config: &LoginRateLimitConfig) -> Self {
let exempt_networks = config.parsed_exempt_networks().unwrap_or_else(|error| {
// AppConfig::validate rejects this at startup; never fail open silently.
tracing::error!(%error, "ignoring invalid login_rate_limit.exempt_networks");
Vec::new()
});
Self {
enabled: config.enabled,
policy: Policy {
failure_window: Duration::from_secs(config.failure_window_secs.max(1)),
lockout: Duration::from_secs(config.lockout_secs.max(1)),
max_lockout: Duration::from_secs(
config.max_lockout_secs.max(config.lockout_secs).max(1),
),
max_tracked_keys: config.max_tracked_keys.max(1),
},
exempt_networks,
accounts: FailureTracker::new(config.account_max_failures),
ips: FailureTracker::new(config.ip_max_failures),
recorded_failures: AtomicU64::new(0),
}
}
/// Normalized account key: the same mailbox must not get a fresh budget by
/// changing letter case or adding whitespace.
pub fn account_key(email: &str) -> String {
email.trim().to_lowercase()
}
fn ip_is_tracked(&self, ip: &IpAddr) -> bool {
!self
.exempt_networks
.iter()
.any(|network| network.contains(ip))
}
/// Returns `Err` when the account or client IP is currently locked out.
pub fn check(&self, email: &str, ip: IpAddr) -> Result<(), LoginBlocked> {
self.check_at(email, ip, Instant::now())
}
/// Records a failed login (wrong credentials). Returns the lockout that this
/// failure triggered, if any, so the caller can log it.
pub fn record_failure(&self, email: &str, ip: IpAddr) -> Option<LoginBlocked> {
self.record_failure_at(email, ip, Instant::now())
}
/// Successful login resets the account's failure history. The IP history is
/// kept so an attacker cannot reset a spraying budget with their own account.
pub fn record_success(&self, email: &str) {
if self.enabled {
self.accounts.clear(&Self::account_key(email));
}
}
#[cfg(test)]
fn tracked_keys(&self) -> (usize, usize) {
(self.accounts.len(), self.ips.len())
}
fn check_at(&self, email: &str, ip: IpAddr, now: Instant) -> Result<(), LoginBlocked> {
if !self.enabled {
return Ok(());
}
let account = self
.accounts
.remaining_lock(&Self::account_key(email), now)
.map(|retry_after| LoginBlocked {
retry_after,
scope: LockScope::Account,
});
let ip = if self.ip_is_tracked(&ip) {
self.ips
.remaining_lock(&ip, now)
.map(|retry_after| LoginBlocked {
retry_after,
scope: LockScope::Ip,
})
} else {
None
};
match (account, ip) {
(Some(a), Some(i)) => Err(if a.retry_after >= i.retry_after { a } else { i }),
(Some(blocked), None) | (None, Some(blocked)) => Err(blocked),
(None, None) => Ok(()),
}
}
fn record_failure_at(&self, email: &str, ip: IpAddr, now: Instant) -> Option<LoginBlocked> {
if !self.enabled {
return None;
}
if self
.recorded_failures
.fetch_add(1, Ordering::Relaxed)
.is_multiple_of(PRUNE_EVERY)
{
self.accounts.prune(now, &self.policy);
self.ips.prune(now, &self.policy);
}
let account = self
.accounts
.record_failure(Self::account_key(email), now, &self.policy)
.map(|retry_after| LoginBlocked {
retry_after,
scope: LockScope::Account,
});
let ip = if self.ip_is_tracked(&ip) {
self.ips
.record_failure(ip, now, &self.policy)
.map(|retry_after| LoginBlocked {
retry_after,
scope: LockScope::Ip,
})
} else {
None
};
account.or(ip)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> LoginRateLimitConfig {
LoginRateLimitConfig {
account_max_failures: 3,
ip_max_failures: 5,
failure_window_secs: 60,
lockout_secs: 10,
max_lockout_secs: 40,
..LoginRateLimitConfig::default()
}
}
fn ip(value: &str) -> IpAddr {
value.parse().unwrap()
}
#[test]
fn account_is_locked_after_max_failures_from_any_ip() {
let guard = LoginGuard::from_config(&config());
let t0 = Instant::now();
assert!(
guard
.record_failure_at("a@x.test", ip("192.0.2.1"), t0)
.is_none()
);
assert!(
guard
.record_failure_at("a@x.test", ip("192.0.2.2"), t0)
.is_none()
);
let locked = guard
.record_failure_at("A@X.test ", ip("192.0.2.3"), t0)
.expect("third failure locks the account");
assert_eq!(locked.scope, LockScope::Account);
assert_eq!(locked.retry_after, Duration::from_secs(10));
let blocked = guard
.check_at("a@x.test", ip("198.51.100.9"), t0 + Duration::from_secs(1))
.unwrap_err();
assert_eq!(blocked.scope, LockScope::Account);
assert_eq!(blocked.retry_after_secs(), 9);
assert!(
guard
.check_at("other@x.test", ip("198.51.100.9"), t0)
.is_ok()
);
assert!(
guard
.check_at("a@x.test", ip("198.51.100.9"), t0 + Duration::from_secs(10))
.is_ok()
);
}
#[test]
fn consecutive_lockouts_back_off_exponentially_up_to_the_cap() {
let guard = LoginGuard::from_config(&config());
let mut now = Instant::now();
let mut durations = Vec::new();
for _ in 0..4 {
let mut lock = None;
for _ in 0..3 {
lock = guard.record_failure_at("a@x.test", ip("192.0.2.1"), now);
}
let lock = lock.expect("lockout");
durations.push(lock.retry_after.as_secs());
now += lock.retry_after;
}
assert_eq!(durations, vec![10, 20, 40, 40]);
}
#[test]
fn success_clears_account_but_not_ip_history() {
let guard = LoginGuard::from_config(&config());
let t0 = Instant::now();
let attacker = ip("203.0.113.5");
for n in 0..4 {
guard.record_failure_at(&format!("victim{n}@x.test"), attacker, t0);
}
guard.record_failure_at("a@x.test", attacker, t0);
guard.record_success("a@x.test");
// 5th failure from the same IP (different accounts) locks the IP.
assert!(guard.check_at("new@x.test", attacker, t0).is_err());
assert_eq!(
guard
.check_at("new@x.test", attacker, t0)
.unwrap_err()
.scope,
LockScope::Ip
);
assert_eq!(guard.tracked_keys().0, 4);
}
#[test]
fn failures_outside_the_window_do_not_accumulate() {
let guard = LoginGuard::from_config(&config());
let t0 = Instant::now();
guard.record_failure_at("a@x.test", ip("192.0.2.1"), t0);
guard.record_failure_at("a@x.test", ip("192.0.2.1"), t0);
let later = t0 + Duration::from_secs(61);
assert!(
guard
.record_failure_at("a@x.test", ip("192.0.2.1"), later)
.is_none()
);
assert!(guard.check_at("a@x.test", ip("192.0.2.1"), later).is_ok());
}
#[test]
fn exempt_networks_skip_only_the_ip_lockout() {
let guard = LoginGuard::from_config(&LoginRateLimitConfig {
exempt_networks: vec!["10.0.0.0/8".to_string()],
ip_max_failures: 1,
..config()
});
let t0 = Instant::now();
let internal = ip("10.1.2.3");
guard.record_failure_at("a@x.test", internal, t0);
assert!(guard.check_at("b@x.test", internal, t0).is_ok());
guard.record_failure_at("a@x.test", internal, t0);
guard.record_failure_at("a@x.test", internal, t0);
assert_eq!(
guard.check_at("a@x.test", internal, t0).unwrap_err().scope,
LockScope::Account
);
}
#[test]
fn zero_thresholds_and_disabled_guard_never_block() {
let guard = LoginGuard::from_config(&LoginRateLimitConfig {
account_max_failures: 0,
ip_max_failures: 0,
..config()
});
let t0 = Instant::now();
for _ in 0..50 {
assert!(
guard
.record_failure_at("a@x.test", ip("192.0.2.1"), t0)
.is_none()
);
}
assert!(guard.check_at("a@x.test", ip("192.0.2.1"), t0).is_ok());
let disabled = LoginGuard::from_config(&LoginRateLimitConfig {
enabled: false,
..config()
});
for _ in 0..50 {
disabled.record_failure_at("a@x.test", ip("192.0.2.1"), t0);
}
assert!(disabled.check_at("a@x.test", ip("192.0.2.1"), t0).is_ok());
}
#[test]
fn table_cap_bounds_memory() {
let guard = LoginGuard::from_config(&LoginRateLimitConfig {
max_tracked_keys: 100,
ip_max_failures: 0,
..config()
});
let t0 = Instant::now();
for n in 0..500 {
guard.record_failure_at(&format!("u{n}@x.test"), ip("192.0.2.1"), t0);
}
assert_eq!(guard.tracked_keys().0, 100);
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod csrf; pub mod csrf;
pub mod imap_auth; pub mod imap_auth;
pub mod login_guard;
pub mod middleware; pub mod middleware;
pub mod session; pub mod session;
pub mod user_data; pub mod user_data;
+347
View File
@@ -180,6 +180,125 @@ impl Default for SearchConfig {
} }
} }
#[derive(Debug, Deserialize, Clone)]
pub struct LoginRateLimitConfig {
/// Master switch for all login brute-force protection (per-IP token bucket
/// and failed-attempt lockouts).
#[serde(default = "default_login_rate_limit_enabled")]
pub enabled: bool,
/// Per-IP token bucket for POST /api/auth/login: maximum burst of attempts.
#[serde(default = "default_login_ip_burst")]
pub ip_burst: u32,
/// Per-IP token bucket: one attempt is replenished every N seconds.
#[serde(default = "default_login_ip_replenish_secs")]
pub ip_replenish_secs: u64,
/// Failed logins (any account) from one client IP inside `failure_window_secs`
/// before that IP is locked out. 0 disables the per-IP lockout.
#[serde(default = "default_login_ip_max_failures")]
pub ip_max_failures: u32,
/// Failed logins for one account (case-insensitive e-mail, any IP) inside
/// `failure_window_secs` before that account is locked out. 0 disables it.
#[serde(default = "default_login_account_max_failures")]
pub account_max_failures: u32,
/// Window in which failed attempts are counted.
#[serde(default = "default_login_failure_window_secs")]
pub failure_window_secs: u64,
/// Duration of the first lockout. Each consecutive lockout of the same key
/// doubles the duration, up to `max_lockout_secs`.
#[serde(default = "default_login_lockout_secs")]
pub lockout_secs: u64,
/// Upper bound for the exponential lockout back-off.
#[serde(default = "default_login_max_lockout_secs")]
pub max_lockout_secs: u64,
/// Client IPs/CIDRs that never receive a per-IP lockout (for example an
/// internal monitoring probe). Account lockouts still apply to them.
#[serde(default)]
pub exempt_networks: Vec<String>,
/// Memory cap for each tracking table (accounts and IPs).
#[serde(default = "default_login_max_tracked_keys")]
pub max_tracked_keys: usize,
}
impl Default for LoginRateLimitConfig {
fn default() -> Self {
Self {
enabled: default_login_rate_limit_enabled(),
ip_burst: default_login_ip_burst(),
ip_replenish_secs: default_login_ip_replenish_secs(),
ip_max_failures: default_login_ip_max_failures(),
account_max_failures: default_login_account_max_failures(),
failure_window_secs: default_login_failure_window_secs(),
lockout_secs: default_login_lockout_secs(),
max_lockout_secs: default_login_max_lockout_secs(),
exempt_networks: Vec::new(),
max_tracked_keys: default_login_max_tracked_keys(),
}
}
}
impl LoginRateLimitConfig {
pub fn parsed_exempt_networks(&self) -> Result<Vec<IpNet>, String> {
self.exempt_networks
.iter()
.map(|entry| {
parse_ip_or_network(entry).map_err(|e| {
format!(
"invalid login_rate_limit.exempt_networks entry '{}': {e}",
entry.trim()
)
})
})
.collect()
}
pub fn validate(&self) -> Result<(), String> {
if !self.enabled {
return Ok(());
}
if self.ip_burst == 0 || self.ip_burst > 1000 {
return Err("LOGIN_RATE_LIMIT__IP_BURST must be between 1 and 1000".to_string());
}
if self.ip_replenish_secs == 0 || self.ip_replenish_secs > 3600 {
return Err(
"LOGIN_RATE_LIMIT__IP_REPLENISH_SECS must be between 1 and 3600".to_string(),
);
}
if self.failure_window_secs == 0 || self.failure_window_secs > 86_400 {
return Err(
"LOGIN_RATE_LIMIT__FAILURE_WINDOW_SECS must be between 1 and 86400".to_string(),
);
}
if self.lockout_secs == 0 || self.lockout_secs > 86_400 {
return Err("LOGIN_RATE_LIMIT__LOCKOUT_SECS must be between 1 and 86400".to_string());
}
if self.max_lockout_secs < self.lockout_secs || self.max_lockout_secs > 7 * 86_400 {
return Err(
"LOGIN_RATE_LIMIT__MAX_LOCKOUT_SECS must be >= lockout_secs and <= 604800"
.to_string(),
);
}
if self.max_tracked_keys < 100 || self.max_tracked_keys > 10_000_000 {
return Err(
"LOGIN_RATE_LIMIT__MAX_TRACKED_KEYS must be between 100 and 10000000".to_string(),
);
}
self.parsed_exempt_networks()?;
Ok(())
}
}
fn parse_ip_or_network(entry: &str) -> Result<IpNet, String> {
let trimmed = entry.trim();
if let Ok(network) = trimmed.parse::<IpNet>() {
return Ok(network);
}
let ip = trimmed.parse::<IpAddr>().map_err(|e| e.to_string())?;
Ok(match ip {
IpAddr::V4(value) => IpNet::V4(Ipv4Net::new(value, 32).expect("valid IPv4 prefix")),
IpAddr::V6(value) => IpNet::V6(Ipv6Net::new(value, 128).expect("valid IPv6 prefix")),
})
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct AppConfig { pub struct AppConfig {
@@ -213,6 +332,26 @@ pub struct AppConfig {
#[serde(default = "default_max_message_size_mb")] #[serde(default = "default_max_message_size_mb")]
pub max_message_size_mb: usize, pub max_message_size_mb: usize,
/// Maximum number of requests that may hold attachment data in memory at
/// the same time while a message is sent, an IMAP draft snapshot is saved
/// or a message is attached. Peak RAM for these operations is roughly
/// `max_concurrent_message_builds` x a few times `max_message_size_mb`.
#[serde(default = "default_max_concurrent_message_builds")]
pub max_concurrent_message_builds: usize,
/// How long a request waits for a free slot before it is answered with 503.
#[serde(default = "default_message_build_wait_secs")]
pub message_build_wait_secs: u64,
/// How long a sent message is held back before actually reaching SMTP, so
/// the sender can cancel it ("Undo Send"). `0` disables the feature and
/// sends immediately, exactly as before this option existed. The built
/// message (including attachments) stays in memory for the whole window,
/// bounded by the same `max_concurrent_message_builds` slot it already
/// holds for an immediate send.
#[serde(default = "default_undo_send_window_secs")]
pub undo_send_window_secs: u64,
#[serde(default = "default_data_dir")] #[serde(default = "default_data_dir")]
pub data_dir: String, pub data_dir: String,
@@ -264,6 +403,9 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub diagnostics_endpoint: DiagnosticsEndpointConfig, pub diagnostics_endpoint: DiagnosticsEndpointConfig,
#[serde(default)]
pub login_rate_limit: LoginRateLimitConfig,
#[serde(default = "default_session_timeout_hours")] #[serde(default = "default_session_timeout_hours")]
pub session_timeout_hours: u64, pub session_timeout_hours: u64,
@@ -276,6 +418,11 @@ pub struct AppConfig {
#[serde(default = "default_environment")] #[serde(default = "default_environment")]
pub environment: String, pub environment: String,
/// `Secure` attribute of session cookies: `auto` (only when the browser
/// reached RustBox over HTTPS), `always` or `never`.
#[serde(default = "default_cookie_secure")]
pub cookie_secure: String,
#[serde(default = "default_theme")] #[serde(default = "default_theme")]
pub default_theme: String, pub default_theme: String,
@@ -401,6 +548,18 @@ fn default_max_message_size_mb() -> usize {
50 50
} }
fn default_max_concurrent_message_builds() -> usize {
4
}
fn default_message_build_wait_secs() -> u64 {
30
}
fn default_undo_send_window_secs() -> u64 {
8
}
fn default_data_dir() -> String { fn default_data_dir() -> String {
"/data".to_string() "/data".to_string()
} }
@@ -473,6 +632,42 @@ fn default_search_worker_max_attempts() -> u32 {
12 12
} }
fn default_login_rate_limit_enabled() -> bool {
true
}
fn default_login_ip_burst() -> u32 {
5
}
fn default_login_ip_replenish_secs() -> u64 {
12
}
fn default_login_ip_max_failures() -> u32 {
20
}
fn default_login_account_max_failures() -> u32 {
5
}
fn default_login_failure_window_secs() -> u64 {
900
}
fn default_login_lockout_secs() -> u64 {
300
}
fn default_login_max_lockout_secs() -> u64 {
3600
}
fn default_login_max_tracked_keys() -> usize {
100_000
}
fn default_log_level() -> String { fn default_log_level() -> String {
"info".to_string() "info".to_string()
} }
@@ -508,6 +703,10 @@ fn default_environment() -> String {
"development".to_string() "development".to_string()
} }
fn default_cookie_secure() -> String {
"auto".to_string()
}
fn default_theme() -> String { fn default_theme() -> String {
"system".to_string() "system".to_string()
} }
@@ -562,6 +761,9 @@ impl Default for AppConfig {
tls_verify: None, tls_verify: None,
max_attachment_size_mb: default_max_attachment_size_mb(), max_attachment_size_mb: default_max_attachment_size_mb(),
max_message_size_mb: default_max_message_size_mb(), max_message_size_mb: default_max_message_size_mb(),
max_concurrent_message_builds: default_max_concurrent_message_builds(),
message_build_wait_secs: default_message_build_wait_secs(),
undo_send_window_secs: default_undo_send_window_secs(),
data_dir: default_data_dir(), data_dir: default_data_dir(),
log_dir: None, log_dir: None,
log_level: default_log_level(), log_level: default_log_level(),
@@ -579,10 +781,12 @@ impl Default for AppConfig {
search: SearchConfig::default(), search: SearchConfig::default(),
sync: SyncConfig::default(), sync: SyncConfig::default(),
diagnostics_endpoint: DiagnosticsEndpointConfig::default(), diagnostics_endpoint: DiagnosticsEndpointConfig::default(),
login_rate_limit: LoginRateLimitConfig::default(),
session_timeout_hours: default_session_timeout_hours(), session_timeout_hours: default_session_timeout_hours(),
remember_me_enabled: default_remember_me_enabled(), remember_me_enabled: default_remember_me_enabled(),
remember_me_days: default_remember_me_days(), remember_me_days: default_remember_me_days(),
environment: default_environment(), environment: default_environment(),
cookie_secure: default_cookie_secure(),
default_theme: default_theme(), default_theme: default_theme(),
default_interface_skin: default_interface_skin(), default_interface_skin: default_interface_skin(),
base_path: None, base_path: None,
@@ -834,6 +1038,15 @@ impl AppConfig {
if self.max_attachment_size_mb > self.max_message_size_mb { if self.max_attachment_size_mb > self.max_message_size_mb {
return Err("MAX_ATTACHMENT_SIZE_MB cannot exceed MAX_MESSAGE_SIZE_MB".to_string()); return Err("MAX_ATTACHMENT_SIZE_MB cannot exceed MAX_MESSAGE_SIZE_MB".to_string());
} }
if self.max_concurrent_message_builds == 0 || self.max_concurrent_message_builds > 256 {
return Err("MAX_CONCURRENT_MESSAGE_BUILDS must be between 1 and 256".to_string());
}
if self.message_build_wait_secs == 0 || self.message_build_wait_secs > 300 {
return Err("MESSAGE_BUILD_WAIT_SECS must be between 1 and 300".to_string());
}
if self.undo_send_window_secs > 120 {
return Err("UNDO_SEND_WINDOW_SECS must be between 0 and 120".to_string());
}
if !matches!(self.database_backend.as_str(), "sqlite" | "mysql" | "pgsql") { if !matches!(self.database_backend.as_str(), "sqlite" | "mysql" | "pgsql") {
return Err("DATABASE_BACKEND must be one of: sqlite, mysql, pgsql".to_string()); return Err("DATABASE_BACKEND must be one of: sqlite, mysql, pgsql".to_string());
} }
@@ -1174,6 +1387,13 @@ impl AppConfig {
} }
} }
self.parsed_outgoing_headers()?; self.parsed_outgoing_headers()?;
self.login_rate_limit.validate()?;
if !matches!(
self.cookie_secure.trim().to_ascii_lowercase().as_str(),
"auto" | "always" | "never"
) {
return Err("COOKIE_SECURE must be one of: auto, always, never".to_string());
}
if self.pgp_network_timeout_secs == 0 || self.pgp_network_timeout_secs > 60 { if self.pgp_network_timeout_secs == 0 || self.pgp_network_timeout_secs > 60 {
return Err("PGP_NETWORK_TIMEOUT_SECS must be between 1 and 60".to_string()); return Err("PGP_NETWORK_TIMEOUT_SECS must be between 1 and 60".to_string());
} }
@@ -1211,6 +1431,9 @@ mod tests {
assert!(config.tls_verify_for_host("mail.example.com")); assert!(config.tls_verify_for_host("mail.example.com"));
assert_eq!(config.max_attachment_size_mb, 10); assert_eq!(config.max_attachment_size_mb, 10);
assert_eq!(config.max_message_size_mb, 50); assert_eq!(config.max_message_size_mb, 50);
assert_eq!(config.max_concurrent_message_builds, 4);
assert_eq!(config.message_build_wait_secs, 30);
assert_eq!(config.undo_send_window_secs, 8);
assert_eq!(config.data_dir, "/data"); assert_eq!(config.data_dir, "/data");
assert!(config.log_dir.is_none()); assert!(config.log_dir.is_none());
assert_eq!(config.database_backend, "sqlite"); assert_eq!(config.database_backend, "sqlite");
@@ -1553,4 +1776,128 @@ mod tests {
let error = config.validate().unwrap_err(); let error = config.validate().unwrap_err();
assert!(error.contains("shared mysql/pgsql application database")); assert!(error.contains("shared mysql/pgsql application database"));
} }
#[test]
fn login_rate_limit_defaults_are_enabled_and_valid() {
let config: AppConfig = Figment::new().extract().expect("defaults should load");
let limit = &config.login_rate_limit;
assert!(limit.enabled);
assert_eq!(limit.ip_burst, 5);
assert_eq!(limit.ip_replenish_secs, 12);
assert_eq!(limit.ip_max_failures, 20);
assert_eq!(limit.account_max_failures, 5);
assert_eq!(limit.failure_window_secs, 900);
assert_eq!(limit.lockout_secs, 300);
assert_eq!(limit.max_lockout_secs, 3600);
assert!(limit.exempt_networks.is_empty());
assert!(limit.validate().is_ok());
assert_eq!(config.cookie_secure, "auto");
}
#[test]
fn message_build_limits_are_validated() {
let valid = AppConfig {
imap_host: Some("imap.example.com".to_string()),
..AppConfig::default()
};
assert!(valid.validate().is_ok());
for builds in [0usize, 257] {
let config = AppConfig {
max_concurrent_message_builds: builds,
..valid.clone()
};
assert!(
config
.validate()
.unwrap_err()
.contains("MAX_CONCURRENT_MESSAGE_BUILDS")
);
}
for wait in [0u64, 301] {
let config = AppConfig {
message_build_wait_secs: wait,
..valid.clone()
};
assert!(
config
.validate()
.unwrap_err()
.contains("MESSAGE_BUILD_WAIT_SECS")
);
}
}
#[test]
fn undo_send_window_is_validated() {
let valid = AppConfig {
imap_host: Some("imap.example.com".to_string()),
..AppConfig::default()
};
assert!(valid.validate().is_ok());
let disabled = AppConfig {
undo_send_window_secs: 0,
..valid.clone()
};
assert!(disabled.validate().is_ok());
let too_long = AppConfig {
undo_send_window_secs: 121,
..valid
};
assert!(
too_long
.validate()
.unwrap_err()
.contains("UNDO_SEND_WINDOW_SECS")
);
}
#[test]
fn invalid_cookie_secure_mode_is_rejected() {
let config = AppConfig {
imap_host: Some("imap.example.com".to_string()),
cookie_secure: "sometimes".to_string(),
..AppConfig::default()
};
assert!(config.validate().unwrap_err().contains("COOKIE_SECURE"));
}
#[test]
fn login_rate_limit_is_read_from_nested_config() {
let config: AppConfig = Figment::new()
.merge(("login_rate_limit.account_max_failures", 3u32))
.merge(("login_rate_limit.lockout_secs", 60u64))
.merge((
"login_rate_limit.exempt_networks",
vec!["10.0.0.0/8", "192.0.2.7"],
))
.extract()
.expect("nested login_rate_limit should load");
assert_eq!(config.login_rate_limit.account_max_failures, 3);
assert_eq!(config.login_rate_limit.lockout_secs, 60);
assert_eq!(
config
.login_rate_limit
.parsed_exempt_networks()
.unwrap()
.len(),
2
);
}
#[test]
fn invalid_login_rate_limit_is_rejected() {
let mut limit = LoginRateLimitConfig {
ip_burst: 0,
..LoginRateLimitConfig::default()
};
assert!(limit.validate().is_err());
limit.ip_burst = 5;
limit.max_lockout_secs = limit.lockout_secs - 1;
assert!(limit.validate().is_err());
limit.max_lockout_secs = 3600;
limit.exempt_networks = vec!["not-an-ip".to_string()];
assert!(limit.validate().is_err());
limit.enabled = false;
assert!(limit.validate().is_ok());
}
} }
+1
View File
@@ -16,6 +16,7 @@ pub mod pool;
pub mod response_templates; pub mod response_templates;
pub mod search_outbox; pub mod search_outbox;
pub mod settings; pub mod settings;
pub mod snooze;
pub mod special_folders; pub mod special_folders;
pub mod tags; pub mod tags;
pub mod trusted_senders; pub mod trusted_senders;
+24 -9
View File
@@ -21,20 +21,33 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql"); const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql"); const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[( const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
(
51, 51,
include_str!( include_str!(
"../../migrations/sqlite/V051__adaptive_email_colors_and_message_loading_mode.sql" "../../migrations/sqlite/V051__adaptive_email_colors_and_message_loading_mode.sql"
), ),
)]; ),
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[( (52, include_str!("../../migrations/sqlite/V052__snooze.sql")),
];
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
(
51, 51,
include_str!("../../migrations/mysql/V051__adaptive_email_colors_and_message_loading_mode.sql"), include_str!(
)]; "../../migrations/mysql/V051__adaptive_email_colors_and_message_loading_mode.sql"
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[( ),
),
(52, include_str!("../../migrations/mysql/V052__snooze.sql")),
];
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
(
51, 51,
include_str!("../../migrations/pgsql/V051__adaptive_email_colors_and_message_loading_mode.sql"), include_str!(
)]; "../../migrations/pgsql/V051__adaptive_email_colors_and_message_loading_mode.sql"
),
),
(52, include_str!("../../migrations/pgsql/V052__snooze.sql")),
];
const TENANT_TABLES: &[&str] = &[ const TENANT_TABLES: &[&str] = &[
"blob_delete_outbox", "blob_delete_outbox",
@@ -61,6 +74,7 @@ const TENANT_TABLES: &[&str] = &[
"response_templates", "response_templates",
"saved_searches", "saved_searches",
"search_outbox", "search_outbox",
"snoozed_messages",
"special_folders", "special_folders",
"tags", "tags",
"trusted_senders", "trusted_senders",
@@ -87,6 +101,7 @@ const IMPORT_TABLES: &[&str] = &[
"messages", "messages",
"tags", "tags",
"message_tags", "message_tags",
"snoozed_messages",
"notification_preferences", "notification_preferences",
"pgp_keys", "pgp_keys",
"pgp_preferences", "pgp_preferences",
@@ -2648,7 +2663,7 @@ mod tests {
|row| row.get(0), |row| row.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(version, 51); assert_eq!(version, 52);
let adaptive: i64 = conn let adaptive: i64 = conn
.query_row( .query_row(
+191
View File
@@ -0,0 +1,191 @@
//! Snoozing a message hides it from its folder listing until a chosen wake
//! time. This is purely local cache state: nothing is moved on the real IMAP
//! server, so snoozing never risks the actual mailbox. The client is
//! responsible for excluding a still-sleeping message from the folder it
//! renders and for showing the "Snoozed" view built from [`list_snoozed`].
use std::time::{SystemTime, UNIX_EPOCH};
use crate::db::pool::{Connection, params};
pub fn now_epoch() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.min(i64::MAX as u64) as i64
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SnoozedMessage {
pub id: String,
pub message_uid: u32,
pub message_folder: String,
pub wake_at: i64,
pub subject: String,
pub from_name: String,
pub from_address: String,
pub snippet: String,
}
/// Snoozes `(folder, uid)` until `wake_at` (epoch seconds), replacing any
/// existing snooze for the same message. Returns the new snooze id.
pub fn snooze_message(
conn: &Connection,
folder: &str,
uid: u32,
wake_at: i64,
) -> Result<String, String> {
if folder.trim().is_empty() {
return Err("folder must not be empty".to_string());
}
if wake_at <= now_epoch() {
return Err("snooze wake time must be in the future".to_string());
}
conn.execute(
crate::dbq!(db_snoozed_messages::Q_002),
params![uid, folder],
)
.map_err(|e| format!("failed to clear existing snooze: {e}"))?;
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
crate::dbq!(db_snoozed_messages::Q_001),
params![&id, uid, folder, wake_at],
)
.map_err(|e| format!("failed to snooze message: {e}"))?;
Ok(id)
}
/// Removes any snooze on `(folder, uid)`. Returns whether a row was removed.
pub fn unsnooze_message(conn: &Connection, folder: &str, uid: u32) -> Result<bool, String> {
let changed = conn
.execute(
crate::dbq!(db_snoozed_messages::Q_002),
params![uid, folder],
)
.map_err(|e| format!("failed to unsnooze message: {e}"))?;
Ok(changed > 0)
}
/// Every snoozed message (woken or not), joined with the cached message
/// metadata needed to render the "Snoozed" view, ordered by wake time.
pub fn list_snoozed(conn: &Connection) -> Result<Vec<SnoozedMessage>, String> {
let mut stmt = conn
.prepare(crate::dbq!(db_snoozed_messages::Q_003))
.map_err(|e| format!("failed to prepare snoozed-messages query: {e}"))?;
let rows = stmt
.query_map([], |row| {
Ok(SnoozedMessage {
id: row.get(0)?,
message_uid: row.get::<_, i64>(1)?.max(0) as u32,
message_folder: row.get(2)?,
wake_at: row.get(3)?,
subject: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
from_name: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
from_address: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
snippet: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
})
})
.map_err(|e| format!("failed to query snoozed messages: {e}"))?;
rows.map(|row| row.map_err(|e| format!("failed to read snoozed message row: {e}")))
.collect()
}
/// The UIDs in `folder` that are still asleep as of `now` (i.e. due to be
/// hidden from that folder's listing right now). Cheap enough for the client
/// to call once per folder open; no IMAP or background work is involved.
pub fn list_sleeping_uids_in_folder(
conn: &Connection,
folder: &str,
now: i64,
) -> Result<Vec<u32>, String> {
let mut stmt = conn
.prepare(crate::dbq!(db_snoozed_messages::Q_004))
.map_err(|e| format!("failed to prepare sleeping-uids query: {e}"))?;
let rows = stmt
.query_map(params![folder, now], |row| {
Ok(row.get::<_, i64>(0)?.max(0) as u32)
})
.map_err(|e| format!("failed to query sleeping uids: {e}"))?;
rows.map(|row| row.map_err(|e| format!("failed to read sleeping uid row: {e}")))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::pool::open_test_db;
fn insert_cached_message(conn: &Connection, folder: &str, uid: u32, subject: &str) {
conn.execute(
"INSERT OR IGNORE INTO folders (name) VALUES (?1)",
params![folder],
)
.unwrap();
conn.execute(
"INSERT INTO messages (uid, folder, subject, date, size) VALUES (?1, ?2, ?3, '', 0)",
params![uid, folder, subject],
)
.unwrap();
}
#[test]
fn snoozing_rejects_a_wake_time_in_the_past() {
let conn = open_test_db();
let error = snooze_message(&conn, "INBOX", 1, now_epoch() - 10).unwrap_err();
assert!(error.contains("future"));
}
#[test]
fn re_snoozing_the_same_message_replaces_the_wake_time() {
let conn = open_test_db();
let future = now_epoch() + 3600;
let later = now_epoch() + 7200;
snooze_message(&conn, "INBOX", 42, future).unwrap();
snooze_message(&conn, "INBOX", 42, later).unwrap();
let snoozed = list_snoozed(&conn).unwrap();
assert_eq!(snoozed.len(), 1);
assert_eq!(snoozed[0].wake_at, later);
}
#[test]
fn unsnoozing_removes_the_entry() {
let conn = open_test_db();
snooze_message(&conn, "INBOX", 7, now_epoch() + 60).unwrap();
assert!(unsnooze_message(&conn, "INBOX", 7).unwrap());
assert!(list_snoozed(&conn).unwrap().is_empty());
assert!(!unsnooze_message(&conn, "INBOX", 7).unwrap());
}
#[test]
fn list_snoozed_carries_cached_message_metadata() {
let conn = open_test_db();
insert_cached_message(&conn, "INBOX", 11, "Quarterly report");
snooze_message(&conn, "INBOX", 11, now_epoch() + 60).unwrap();
let snoozed = list_snoozed(&conn).unwrap();
assert_eq!(snoozed.len(), 1);
assert_eq!(snoozed[0].subject, "Quarterly report");
}
#[test]
fn sleeping_uids_exclude_already_woken_snoozes() {
let conn = open_test_db();
let now = now_epoch();
snooze_message(&conn, "INBOX", 1, now + 60).unwrap();
snooze_message(&conn, "INBOX", 2, now + 120).unwrap();
let sleeping = list_sleeping_uids_in_folder(&conn, "INBOX", now).unwrap();
assert_eq!(sleeping.len(), 2);
let sleeping_later = list_sleeping_uids_in_folder(&conn, "INBOX", now + 90).unwrap();
assert_eq!(sleeping_later, vec![2]);
}
#[test]
fn sleeping_uids_are_scoped_to_their_folder() {
let conn = open_test_db();
let now = now_epoch();
snooze_message(&conn, "INBOX", 1, now + 60).unwrap();
snooze_message(&conn, "Archive", 2, now + 60).unwrap();
let sleeping = list_sleeping_uids_in_folder(&conn, "INBOX", now).unwrap();
assert_eq!(sleeping, vec![1]);
}
}
+2
View File
@@ -5,6 +5,7 @@ mod db;
mod email_theme; mod email_theme;
mod error; mod error;
mod imap; mod imap;
mod message_budget;
mod pgp; mod pgp;
mod queries; mod queries;
mod realtime; mod realtime;
@@ -15,6 +16,7 @@ mod sieve;
mod smtp; mod smtp;
mod storage; mod storage;
mod ui; mod ui;
mod undo_send;
use std::fs::OpenOptions; use std::fs::OpenOptions;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
+185
View File
@@ -0,0 +1,185 @@
//! Memory guards for assembling messages that carry attachments.
//!
//! Attachments are streamed to blob storage on upload, but sending a message or
//! saving an IMAP draft snapshot has to load every attachment into memory, and
//! the MIME builder then keeps several copies (raw bytes, base64 body, final
//! RFC 5322 text, the copy appended to the Sent folder). Two guards keep that
//! bounded:
//!
//! * [`ensure_attachments_fit`] rejects an attachment set that cannot fit
//! `max_message_size_mb` *before* any blob is read, using the sizes already
//! stored in the database (and while an upload is still streaming).
//! * [`MessageBuildLimiter`] caps how many requests may hold attachment data in
//! memory at the same time (`max_concurrent_message_builds`). Excess requests
//! wait up to `message_build_wait_secs` and then get `503`.
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::db::drafts::DraftAttachment;
use crate::error::AppError;
/// Allowance for the MIME headers and boundary lines of one attachment part.
const PER_PART_OVERHEAD_BYTES: u64 = 512;
/// Size of `raw_bytes` of attachment data spread over `parts` MIME parts after
/// base64 encoding (4 bytes per 3, plus a CRLF every 76 characters).
pub fn encoded_size_estimate(raw_bytes: u64, parts: usize) -> u64 {
let base64 = raw_bytes.div_ceil(3).saturating_mul(4);
let line_breaks = base64.div_ceil(76).saturating_mul(2);
base64
.saturating_add(line_breaks)
.saturating_add((parts as u64).saturating_mul(PER_PART_OVERHEAD_BYTES))
}
/// Sum of the sizes recorded for stored draft attachments.
pub fn draft_attachments_total(records: &[DraftAttachment]) -> u64 {
records
.iter()
.map(|record| u64::try_from(record.size).unwrap_or(0))
.sum()
}
fn mib(bytes: u64) -> f64 {
bytes as f64 / 1024.0 / 1024.0
}
/// Fails with `413` when `raw_bytes` of attachments (in `parts` parts) would
/// exceed `limit_bytes` once MIME-encoded. A limit of 0 disables the check.
pub fn ensure_attachments_fit(
limit_bytes: usize,
raw_bytes: u64,
parts: usize,
) -> Result<(), AppError> {
if limit_bytes == 0 {
return Ok(());
}
let estimate = encoded_size_estimate(raw_bytes, parts);
if estimate > limit_bytes as u64 {
return Err(AppError::PayloadTooLarge(format!(
"Attachments total {:.2} MiB ({:.2} MiB after MIME encoding); the message size limit is {:.2} MiB",
mib(raw_bytes),
mib(estimate),
mib(limit_bytes as u64)
)));
}
Ok(())
}
/// Bounds the number of requests that hold attachment data in memory at once.
#[derive(Debug)]
pub struct MessageBuildLimiter {
semaphore: Arc<Semaphore>,
wait: Duration,
}
impl MessageBuildLimiter {
pub fn new(max_concurrent: usize, wait: Duration) -> Self {
Self {
semaphore: Arc::new(Semaphore::new(max_concurrent.max(1))),
wait,
}
}
/// Waits (first come, first served) for a free slot. Keep the returned
/// permit alive for as long as the attachment data is in memory.
pub async fn acquire(&self) -> Result<OwnedSemaphorePermit, AppError> {
match tokio::time::timeout(self.wait, self.semaphore.clone().acquire_owned()).await {
Ok(Ok(permit)) => Ok(permit),
Ok(Err(_closed)) => Err(AppError::InternalError(
"message build limiter is closed".to_string(),
)),
Err(_elapsed) => {
tracing::warn!(
wait_secs = self.wait.as_secs_f32(),
"too many messages with attachments are being assembled; request rejected"
);
Err(AppError::ServiceUnavailable(
"The server is busy assembling other messages with attachments; try again in a moment"
.to_string(),
))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn estimate_accounts_for_base64_line_breaks_and_part_overhead() {
// 57 raw bytes -> 76 base64 characters -> one CRLF -> +512 part overhead.
assert_eq!(encoded_size_estimate(57, 1), 76 + 2 + 512);
assert_eq!(encoded_size_estimate(0, 0), 0);
let raw = 9_000_000u64;
let estimate = encoded_size_estimate(raw, 1);
assert!(estimate > raw * 4 / 3);
assert!(estimate < raw * 4 / 3 + raw / 20);
}
#[test]
fn attachments_over_the_message_limit_are_rejected_before_loading() {
let limit = 10 * 1024 * 1024;
assert!(ensure_attachments_fit(limit, 7_000_000, 2).is_ok());
let error = ensure_attachments_fit(limit, 8_000_000, 2).unwrap_err();
assert!(matches!(error, AppError::PayloadTooLarge(_)));
assert!(
error
.to_string()
.contains("message size limit is 10.00 MiB")
);
// The same bytes split over many parts cost extra overhead.
assert!(ensure_attachments_fit(limit, 7_300_000, 1).is_ok());
assert!(ensure_attachments_fit(limit, 7_300_000, 5_000).is_err());
assert!(ensure_attachments_fit(0, u64::MAX, 1).is_ok());
}
#[test]
fn totals_ignore_invalid_negative_sizes() {
let record = |size: i64| DraftAttachment {
id: "a".to_string(),
draft_id: "d".to_string(),
filename: "f".to_string(),
content_type: "application/octet-stream".to_string(),
size,
storage_key: "k".to_string(),
checksum_sha256: None,
created_at: String::new(),
};
assert_eq!(
draft_attachments_total(&[record(10), record(-5), record(32)]),
42
);
}
#[tokio::test]
async fn limiter_queues_then_rejects_when_slots_stay_busy() {
let limiter = MessageBuildLimiter::new(1, Duration::from_millis(60));
let first = limiter.acquire().await.expect("first slot");
let error = limiter.acquire().await.unwrap_err();
assert!(matches!(error, AppError::ServiceUnavailable(_)));
drop(first);
let _second = limiter
.acquire()
.await
.expect("slot is available again after the permit is dropped");
}
#[tokio::test]
async fn limiter_hands_a_slot_to_a_waiting_request() {
let limiter = Arc::new(MessageBuildLimiter::new(1, Duration::from_secs(5)));
let first = limiter.acquire().await.unwrap();
let waiting = {
let limiter = limiter.clone();
tokio::spawn(async move { limiter.acquire().await.map(|_| ()) })
};
tokio::time::sleep(Duration::from_millis(30)).await;
drop(first);
assert!(waiting.await.unwrap().is_ok());
}
}
+12
View File
@@ -406,6 +406,18 @@ pub mod db_saved_searches {
pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=?"; pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=?";
} }
pub mod db_snoozed_messages {
pub const Q_001: &str = "INSERT INTO snoozed_messages (id, message_uid, message_folder, wake_at) VALUES (?, ?, ?, ?)";
pub const Q_002: &str =
"DELETE FROM snoozed_messages WHERE message_uid = ? AND message_folder = ?";
pub const Q_003: &str =
"SELECT s.id, s.message_uid, s.message_folder, s.wake_at, m.subject, m.from_name, m.from_address, m.snippet
FROM snoozed_messages s
LEFT JOIN messages m ON m.uid = s.message_uid AND m.folder = s.message_folder
ORDER BY s.wake_at ASC";
pub const Q_004: &str = "SELECT message_uid FROM snoozed_messages WHERE message_folder = ? AND wake_at > ? ORDER BY wake_at ASC";
}
pub mod db_special_folders { pub mod db_special_folders {
pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = ?"; pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = ?";
pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)"; pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)";
+12
View File
@@ -406,6 +406,18 @@ pub mod db_saved_searches {
pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=$1"; pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=$1";
} }
pub mod db_snoozed_messages {
pub const Q_001: &str = "INSERT INTO snoozed_messages (id, message_uid, message_folder, wake_at) VALUES ($1, $2, $3, $4)";
pub const Q_002: &str =
"DELETE FROM snoozed_messages WHERE message_uid = $1 AND message_folder = $2";
pub const Q_003: &str =
"SELECT s.id, s.message_uid, s.message_folder, s.wake_at, m.subject, m.from_name, m.from_address, m.snippet
FROM snoozed_messages s
LEFT JOIN messages m ON m.uid = s.message_uid AND m.folder = s.message_folder
ORDER BY s.wake_at ASC";
pub const Q_004: &str = "SELECT message_uid FROM snoozed_messages WHERE message_folder = $1 AND wake_at > $2 ORDER BY wake_at ASC";
}
pub mod db_special_folders { pub mod db_special_folders {
pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = $1"; pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = $1";
pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)"; pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)";
+12
View File
@@ -403,6 +403,18 @@ pub mod db_saved_searches {
pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=?1"; pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=?1";
} }
pub mod db_snoozed_messages {
pub const Q_001: &str = "INSERT INTO snoozed_messages (id, message_uid, message_folder, wake_at) VALUES (?1, ?2, ?3, ?4)";
pub const Q_002: &str =
"DELETE FROM snoozed_messages WHERE message_uid = ?1 AND message_folder = ?2";
pub const Q_003: &str =
"SELECT s.id, s.message_uid, s.message_folder, s.wake_at, m.subject, m.from_name, m.from_address, m.snippet
FROM snoozed_messages s
LEFT JOIN messages m ON m.uid = s.message_uid AND m.folder = s.message_folder
ORDER BY s.wake_at ASC";
pub const Q_004: &str = "SELECT message_uid FROM snoozed_messages WHERE message_folder = ?1 AND wake_at > ?2 ORDER BY wake_at ASC";
}
pub mod db_special_folders { pub mod db_special_folders {
pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = ?1"; pub const Q_001: &str = "SELECT folder_name FROM special_folders WHERE role = ?1";
pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)"; pub const Q_002: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)";
+35
View File
@@ -13,6 +13,7 @@ use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::error::AppError; use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
use crate::message_budget::{MessageBuildLimiter, draft_attachments_total, ensure_attachments_fit};
use crate::storage::{self, BlobStorage, attachment_key}; use crate::storage::{self, BlobStorage, attachment_key};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -56,6 +57,13 @@ pub async fn upload_attachment(
let mut uploaded = Vec::new(); let mut uploaded = Vec::new();
let max_attachment_size = config.max_attachment_size_bytes(); let max_attachment_size = config.max_attachment_size_bytes();
// The whole draft has to fit into one message, so stop an upload as soon as
// the attachments already stored plus this request would exceed the limit.
let max_message_size = config.max_message_size_bytes();
let existing_attachments =
db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?;
let mut message_attachment_bytes = draft_attachments_total(&existing_attachments);
let mut message_attachment_parts = existing_attachments.len();
while let Some(mut field) = multipart while let Some(mut field) = multipart
.next_field() .next_field()
@@ -76,6 +84,7 @@ pub async fn upload_attachment(
.map_err(|e| AppError::ServiceUnavailable(format!("Storage unavailable: {e}")))?; .map_err(|e| AppError::ServiceUnavailable(format!("Storage unavailable: {e}")))?;
let mut size: usize = 0; let mut size: usize = 0;
let mut checksum = Sha256::new(); let mut checksum = Sha256::new();
message_attachment_parts += 1;
loop { loop {
let chunk = match field.chunk().await { let chunk = match field.chunk().await {
@@ -107,6 +116,19 @@ pub async fn upload_attachment(
filename, config.max_attachment_size_mb filename, config.max_attachment_size_mb
))); )));
} }
if let Err(error) = ensure_attachments_fit(
max_message_size,
message_attachment_bytes.saturating_add(size as u64),
message_attachment_parts,
) {
if writer.abort().await.is_err()
&& let Err(cleanup_error) =
storage::enqueue_unreferenced_delete(&conn, storage.as_ref(), &key).await
{
tracing::error!(error = %cleanup_error, storage_key = %key, "Failed to queue over-limit attachment cleanup");
}
return Err(error);
}
checksum.update(&chunk); checksum.update(&chunk);
if let Err(e) = writer.write(chunk).await { if let Err(e) = writer.write(chunk).await {
if writer.abort().await.is_err() if writer.abort().await.is_err()
@@ -145,6 +167,7 @@ pub async fn upload_attachment(
))); )));
} }
let checksum = format!("{:x}", checksum.finalize()); let checksum = format!("{:x}", checksum.finalize());
message_attachment_bytes = message_attachment_bytes.saturating_add(size as u64);
let size = size as i64; let size = size as i64;
let attachment = db::drafts::NewDraftAttachment { let attachment = db::drafts::NewDraftAttachment {
id: &att_id, id: &att_id,
@@ -187,6 +210,7 @@ pub async fn attach_message_source(
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(storage): Extension<Arc<dyn BlobStorage>>, Extension(storage): Extension<Arc<dyn BlobStorage>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Extension(build_limiter): Extension<Arc<MessageBuildLimiter>>,
AxumPath(draft_id): AxumPath<String>, AxumPath(draft_id): AxumPath<String>,
Json(req): Json<AttachMessageSourceRequest>, Json(req): Json<AttachMessageSourceRequest>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
@@ -205,6 +229,9 @@ pub async fn attach_message_source(
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
// The whole source message is fetched into memory: bound how many of these
// (and message builds) run at the same time.
let _build_permit = build_limiter.acquire().await?;
let raw = imap_client let raw = imap_client
.fetch_raw_message(&creds, &req.folder, req.uid) .fetch_raw_message(&creds, &req.folder, req.uid)
.await .await
@@ -232,6 +259,14 @@ pub async fn attach_message_source(
.map_err(AppError::InternalError)?; .map_err(AppError::InternalError)?;
} }
let existing_attachments =
db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?;
ensure_attachments_fit(
config.max_message_size_bytes(),
draft_attachments_total(&existing_attachments).saturating_add(raw.len() as u64),
existing_attachments.len() + 1,
)?;
let filename = format!("message-{}.eml", req.uid); let filename = format!("message-{}.eml", req.uid);
let content_type = "message/rfc822".to_string(); let content_type = "message/rfc822".to_string();
let att_id = uuid::Uuid::new_v4().to_string(); let att_id = uuid::Uuid::new_v4().to_string();
+352 -5
View File
@@ -1,12 +1,14 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
use axum::extract::Path; use axum::extract::{ConnectInfo, Path};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{Extension, Json}; use axum::{Extension, Json};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::auth::imap_auth::{self, AuthResult}; use crate::auth::imap_auth::{self, AuthResult};
use crate::auth::login_guard::{LoginBlocked, LoginGuard};
use crate::auth::session::{NewAccountSession, SessionState, SessionStore}; use crate::auth::session::{NewAccountSession, SessionState, SessionStore};
use crate::auth::user_data; use crate::auth::user_data;
use crate::config::AppConfig; use crate::config::AppConfig;
@@ -150,9 +152,133 @@ fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, Stri
sessions sessions
} }
/// Client IP used for login throttling; honours `trusted_proxies` the same way
/// as the per-IP token bucket and the security telemetry.
fn login_client_ip(
config: &AppConfig,
extensions: &axum::http::Extensions,
headers: &axum::http::HeaderMap,
) -> IpAddr {
crate::routes::system_diagnostics::effective_client_ip(config, headers, peer_ip(extensions))
}
fn peer_ip(extensions: &axum::http::Extensions) -> IpAddr {
extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|info| info.0.ip())
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
}
/// Whether issued/cleared session cookies carry the `Secure` attribute.
///
/// `cookie_secure = "auto"` (default) marks cookies `Secure` only when the
/// browser actually reached RustBox over HTTPS, so plain-HTTP installations
/// keep working: a `Secure` cookie sent over HTTP is discarded by browsers and
/// the user could never stay logged in.
pub(crate) fn cookie_secure(
config: &AppConfig,
extensions: &axum::http::Extensions,
headers: &axum::http::HeaderMap,
) -> bool {
match config.cookie_secure.trim().to_ascii_lowercase().as_str() {
"always" => true,
"never" => false,
_ => request_is_https(config, extensions, headers),
}
}
/// RustBox itself serves plain HTTP, so HTTPS can only be terminated in front
/// of it. A scheme reported by a trusted reverse proxy (`X-Forwarded-Proto`,
/// `Forwarded: proto=`, `X-Forwarded-Ssl`) wins; otherwise the scheme of the
/// browser-supplied `Origin`/`Referer` is used. A forged header can only change
/// the cookies issued to the client that forged it.
fn request_is_https(
config: &AppConfig,
extensions: &axum::http::Extensions,
headers: &axum::http::HeaderMap,
) -> bool {
let peer = peer_ip(extensions);
let peer_is_trusted_proxy = config
.parsed_trusted_proxies()
.iter()
.any(|proxy| proxy.contains(&peer));
if peer_is_trusted_proxy && let Some(proto) = forwarded_proto(headers) {
return proto == "https";
}
header_url_is_https(headers, "origin") || header_url_is_https(headers, "referer")
}
fn forwarded_proto(headers: &axum::http::HeaderMap) -> Option<String> {
let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
if let Some(proto) = header("x-forwarded-proto")
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(proto.to_ascii_lowercase());
}
if let Some(forwarded) = header("forwarded") {
for element in forwarded.split(',') {
for pair in element.split(';') {
if let Some((key, value)) = pair.split_once('=')
&& key.trim().eq_ignore_ascii_case("proto")
{
return Some(value.trim().trim_matches('"').to_ascii_lowercase());
}
}
}
}
if header("x-forwarded-ssl").is_some_and(|value| value.trim().eq_ignore_ascii_case("on")) {
return Some("https".to_string());
}
None
}
fn header_url_is_https(headers: &axum::http::HeaderMap, name: &str) -> bool {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.trim().get(..8))
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
}
fn too_many_login_attempts(blocked: LoginBlocked) -> Response {
let retry_after = blocked.retry_after_secs();
(
StatusCode::TOO_MANY_REQUESTS,
[
("content-type", "application/json".to_string()),
("retry-after", retry_after.to_string()),
],
serde_json::json!({
"error": {
"code": "TOO_MANY_LOGIN_ATTEMPTS",
"message": format!(
"Too many failed login attempts. Try again in {}.",
human_wait(retry_after)
),
"status": 429,
"retryAfterSecs": retry_after
}
})
.to_string(),
)
.into_response()
}
fn human_wait(secs: u64) -> String {
if secs < 60 {
format!("{secs} s")
} else {
format!("{} min", secs.div_ceil(60))
}
}
pub async fn login( pub async fn login(
Extension(store): Extension<Arc<SessionStore>>, Extension(store): Extension<Arc<SessionStore>>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
extensions: axum::http::Extensions,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<LoginRequest>, Json(body): Json<LoginRequest>,
) -> Response { ) -> Response {
@@ -192,6 +318,20 @@ pub async fn login(
.into_response(); .into_response();
}; };
let login_guard = extensions.get::<Arc<LoginGuard>>().cloned();
let client_ip = login_client_ip(&config, &extensions, &headers);
if let Some(guard) = login_guard.as_deref()
&& let Err(blocked) = guard.check(&body.email, client_ip)
{
tracing::warn!(
source_ip = %client_ip,
scope = blocked.scope.as_str(),
retry_after_secs = blocked.retry_after_secs(),
"login refused: brute-force lockout active"
);
return too_many_login_attempts(blocked);
}
let smtp_host = config let smtp_host = config
.smtp_host .smtp_host
.clone() .clone()
@@ -214,6 +354,23 @@ pub async fn login(
) )
.await; .await;
if let Some(guard) = login_guard.as_deref() {
match &result {
AuthResult::Success => guard.record_success(&body.email),
AuthResult::InvalidCredentials => {
if let Some(locked) = guard.record_failure(&body.email, client_ip) {
tracing::warn!(
source_ip = %client_ip,
scope = locked.scope.as_str(),
lockout_secs = locked.retry_after_secs(),
"login brute-force lockout triggered"
);
}
}
AuthResult::ServerUnreachable(_) => {}
}
}
match result { match result {
AuthResult::Success => { AuthResult::Success => {
let provided_browser_id = body let provided_browser_id = body
@@ -366,7 +523,7 @@ pub async fn login(
store.set_user_agent_shared(&account_id, user_agent).await; store.set_user_agent_shared(&account_id, user_agent).await;
let max_age = session_hours * 3600; let max_age = session_hours * 3600;
let secure = config.environment != "development"; let secure = cookie_secure(&config, &extensions, &headers);
let same_site = same_site_policy(&config); let same_site = same_site_policy(&config);
let path = cookie_path(&config); let path = cookie_path(&config);
let browser_cookie_header = let browser_cookie_header =
@@ -439,9 +596,10 @@ pub async fn get_session(Extension(session): Extension<SessionState>) -> Respons
pub async fn list_accounts( pub async fn list_accounts(
Extension(store): Extension<Arc<SessionStore>>, Extension(store): Extension<Arc<SessionStore>>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
extensions: axum::http::Extensions,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
) -> Response { ) -> Response {
let secure = config.environment != "development"; let secure = cookie_secure(&config, &extensions, &headers);
let same_site = same_site_policy(&config); let same_site = same_site_policy(&config);
let browser_id = extract_browser_id(&headers); let browser_id = extract_browser_id(&headers);
@@ -570,6 +728,7 @@ pub async fn remove_account(
Extension(store): Extension<Arc<SessionStore>>, Extension(store): Extension<Arc<SessionStore>>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Path(account_id): Path<String>, Path(account_id): Path<String>,
extensions: axum::http::Extensions,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
) -> Response { ) -> Response {
let browser_id = extract_browser_id(&headers); let browser_id = extract_browser_id(&headers);
@@ -611,7 +770,7 @@ pub async fn remove_account(
store.remove_account_shared(&account_id).await; store.remove_account_shared(&account_id).await;
let secure = config.environment != "development"; let secure = cookie_secure(&config, &extensions, &headers);
let same_site = same_site_policy(&config); let same_site = same_site_policy(&config);
let cookie = clearing_account_cookie(&account_id, secure, same_site, cookie_path(&config)); let cookie = clearing_account_cookie(&account_id, secure, same_site, cookie_path(&config));
@@ -764,13 +923,14 @@ pub async fn revoke_other_sessions(
pub async fn logout( pub async fn logout(
Extension(store): Extension<Arc<SessionStore>>, Extension(store): Extension<Arc<SessionStore>>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
extensions: axum::http::Extensions,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
) -> Response { ) -> Response {
let browser_id = extract_browser_id(&headers); let browser_id = extract_browser_id(&headers);
let mut cookies_to_clear: Vec<String> = Vec::new(); let mut cookies_to_clear: Vec<String> = Vec::new();
let secure = config.environment != "development"; let secure = cookie_secure(&config, &extensions, &headers);
let same_site = same_site_policy(&config); let same_site = same_site_policy(&config);
if let Some(ref browser_id) = browser_id { if let Some(ref browser_id) = browser_id {
@@ -947,6 +1107,7 @@ mod tests {
let first = login( let first = login(
Extension(store.clone()), Extension(store.clone()),
Extension(config.clone()), Extension(config.clone()),
axum::http::Extensions::new(),
axum::http::HeaderMap::new(), axum::http::HeaderMap::new(),
Json(request("first@example.com")), Json(request("first@example.com")),
) )
@@ -968,6 +1129,7 @@ mod tests {
let second = login( let second = login(
Extension(store.clone()), Extension(store.clone()),
Extension(config.clone()), Extension(config.clone()),
axum::http::Extensions::new(),
headers.clone(), headers.clone(),
Json(request("second@example.com")), Json(request("second@example.com")),
) )
@@ -995,6 +1157,7 @@ mod tests {
let duplicate = login( let duplicate = login(
Extension(store.clone()), Extension(store.clone()),
Extension(config), Extension(config),
axum::http::Extensions::new(),
headers, headers,
Json(request("second@example.com")), Json(request("second@example.com")),
) )
@@ -1004,6 +1167,190 @@ mod tests {
imap.await.unwrap(); imap.await.unwrap();
} }
#[tokio::test]
async fn repeated_failed_logins_lock_the_account_before_contacting_imap() {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let connections = Arc::new(AtomicUsize::new(0));
let seen = connections.clone();
let imap = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
seen.fetch_add(1, Ordering::SeqCst);
let mut stream = BufReader::new(stream);
stream
.get_mut()
.write_all(b"* OK test IMAP ready\r\n")
.await
.unwrap();
let mut command = String::new();
stream.read_line(&mut command).await.unwrap();
let tag = command
.split_whitespace()
.next()
.unwrap_or("A1")
.to_string();
let response = format!("{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n");
let _ = stream.get_mut().write_all(response.as_bytes()).await;
}
});
let data_dir = tempfile::tempdir().unwrap();
let mut config = test_config("development", None);
config.data_dir = data_dir.path().to_str().unwrap().to_string();
config.imap_host = Some("127.0.0.1".to_string());
config.imap_port = port;
config.tls_enabled = false;
config.login_rate_limit.account_max_failures = 2;
config.login_rate_limit.lockout_secs = 120;
let guard = Arc::new(LoginGuard::from_config(&config.login_rate_limit));
let config = Arc::new(config);
let store = Arc::new(SessionStore::new(std::time::Duration::from_secs(3600)));
let attempt = |email: &'static str| {
let mut extensions = axum::http::Extensions::new();
extensions.insert(guard.clone());
extensions.insert(ConnectInfo::<SocketAddr>(
"198.51.100.7:50000".parse().unwrap(),
));
login(
Extension(store.clone()),
Extension(config.clone()),
extensions,
axum::http::HeaderMap::new(),
Json(LoginRequest {
email: email.to_string(),
password: "wrong".to_string(),
remember: false,
browser_id: None,
}),
)
};
assert_eq!(
attempt("victim@example.com").await.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
attempt("Victim@Example.com").await.status(),
StatusCode::UNAUTHORIZED
);
let locked = attempt("victim@example.com").await;
assert_eq!(locked.status(), StatusCode::TOO_MANY_REQUESTS);
let retry_after: u64 = locked
.headers()
.get("retry-after")
.unwrap()
.to_str()
.unwrap()
.parse()
.unwrap();
assert!((1..=120).contains(&retry_after));
assert_eq!(connections.load(Ordering::SeqCst), 2);
// Another mailbox from the same IP is still allowed to try.
assert_eq!(
attempt("other@example.com").await.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(connections.load(Ordering::SeqCst), 3);
imap.abort();
}
fn request_parts(
peer: &str,
headers: &[(&'static str, &'static str)],
) -> (axum::http::Extensions, axum::http::HeaderMap) {
let mut extensions = axum::http::Extensions::new();
extensions.insert(ConnectInfo::<SocketAddr>(peer.parse().unwrap()));
let mut map = axum::http::HeaderMap::new();
for &(name, value) in headers {
map.append(name, axum::http::HeaderValue::from_static(value));
}
(extensions, map)
}
fn secure_for(
config: &AppConfig,
peer: &str,
headers: &[(&'static str, &'static str)],
) -> bool {
let (extensions, headers) = request_parts(peer, headers);
cookie_secure(config, &extensions, &headers)
}
#[test]
fn auto_cookie_secure_follows_the_real_request_scheme() {
let mut config = test_config("production", None);
config.trusted_proxies = Some("127.0.0.1,10.0.0.0/8".to_string());
let client = "192.0.2.10:5000";
let proxy = "10.0.0.2:5000";
// Plain HTTP install, even in production: no Secure flag, login keeps working.
assert!(!secure_for(&config, client, &[]));
assert!(!secure_for(
&config,
client,
&[("origin", "http://mail.example.com")]
));
// Browser on HTTPS (TLS terminated in front of RustBox).
assert!(secure_for(
&config,
client,
&[("origin", "https://mail.example.com")]
));
assert!(secure_for(
&config,
"127.0.0.1:5000",
&[("referer", "HTTPS://mail.example.com/inbox")]
));
// Trusted reverse proxy reports the scheme explicitly and wins.
assert!(secure_for(
&config,
proxy,
&[("x-forwarded-proto", "https")]
));
assert!(secure_for(
&config,
proxy,
&[("forwarded", "for=192.0.2.1;proto=https")]
));
assert!(secure_for(&config, proxy, &[("x-forwarded-ssl", "on")]));
assert!(!secure_for(
&config,
proxy,
&[
("x-forwarded-proto", "http"),
("origin", "https://mail.example.com")
]
));
// Untrusted peers cannot assert the scheme through proxy headers.
assert!(!secure_for(
&config,
client,
&[("x-forwarded-proto", "https")]
));
}
#[test]
fn cookie_secure_can_be_forced_from_config() {
let (extensions, headers) = request_parts("192.0.2.10:5000", &[]);
let mut config = test_config("development", None);
config.cookie_secure = "always".to_string();
assert!(cookie_secure(&config, &extensions, &headers));
let (extensions, headers) =
request_parts("192.0.2.10:5000", &[("origin", "https://mail.example.com")]);
config.cookie_secure = "never".to_string();
assert!(!cookie_secure(&config, &extensions, &headers));
}
#[test] #[test]
fn issued_and_clearing_cookies_use_the_normalized_base_path() { fn issued_and_clearing_cookies_use_the_normalized_base_path() {
for (base_path, expected_path) in [ for (base_path, expected_path) in [
+30 -5
View File
@@ -11,6 +11,7 @@ use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::error::AppError; use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
use crate::message_budget::{MessageBuildLimiter, draft_attachments_total, ensure_attachments_fit};
use crate::smtp::client::AttachmentData; use crate::smtp::client::AttachmentData;
use crate::storage::{self, BlobStorage}; use crate::storage::{self, BlobStorage};
@@ -240,6 +241,7 @@ pub async fn upsert_draft_handler(
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Extension(storage): Extension<Arc<dyn BlobStorage>>, Extension(storage): Extension<Arc<dyn BlobStorage>>,
Extension(build_limiter): Extension<Arc<MessageBuildLimiter>>,
Json(req): Json<UpsertDraftRequest>, Json(req): Json<UpsertDraftRequest>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
db::drafts::validate_draft_id(&req.id).map_err(AppError::BadRequest)?; db::drafts::validate_draft_id(&req.id).map_err(AppError::BadRequest)?;
@@ -306,7 +308,23 @@ pub async fn upsert_draft_handler(
resolve_drafts_folder(&conn, &config).map_err(AppError::InternalError)?; resolve_drafts_folder(&conn, &config).map_err(AppError::InternalError)?;
tracing::debug!(folder = %drafts_folder, draft_id = %req.id, "Resolved IMAP drafts folder"); tracing::debug!(folder = %drafts_folder, draft_id = %req.id, "Resolved IMAP drafts folder");
let previous = db::drafts::get_draft(&conn, &req.id).map_err(AppError::InternalError)?; let previous = db::drafts::get_draft(&conn, &req.id).map_err(AppError::InternalError)?;
let mut snapshot_attachments = load_snapshot_attachments(&conn, &req.id, storage.as_ref()) // Check the stored sizes first, then bound how many snapshots hold
// attachment data in RAM at once (released when this block ends).
let snapshot_records =
db::drafts::get_draft_attachments(&conn, &req.id).map_err(AppError::InternalError)?;
ensure_attachments_fit(
config.max_message_size_bytes(),
draft_attachments_total(&snapshot_records),
snapshot_records.len(),
)?;
let loads_attachment_data = !snapshot_records.is_empty()
|| (req.imap_source.is_some() && !req.imap_attachments.is_empty());
let _build_permit = if loads_attachment_data {
Some(build_limiter.acquire().await?)
} else {
None
};
let mut snapshot_attachments = read_snapshot_attachments(storage.as_ref(), snapshot_records)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::warn!(error = %e, draft_id = %req.id, "Failed to load attachments for IMAP draft snapshot"); tracing::warn!(error = %e, draft_id = %req.id, "Failed to load attachments for IMAP draft snapshot");
@@ -322,6 +340,15 @@ pub async fn upsert_draft_handler(
.await?; .await?;
snapshot_attachments.append(&mut remote_attachments); snapshot_attachments.append(&mut remote_attachments);
} }
let snapshot_bytes: u64 = snapshot_attachments
.iter()
.map(|attachment| attachment.data.len() as u64)
.sum();
ensure_attachments_fit(
config.max_message_size_bytes(),
snapshot_bytes,
snapshot_attachments.len(),
)?;
let selected_identity = if let Some(identity_id) = req.from_identity_id { let selected_identity = if let Some(identity_id) = req.from_identity_id {
Some( Some(
db::identities::get_identity(&conn, identity_id) db::identities::get_identity(&conn, identity_id)
@@ -699,12 +726,10 @@ async fn delete_imap_draft_source(
Ok(true) Ok(true)
} }
async fn load_snapshot_attachments( async fn read_snapshot_attachments(
conn: &crate::db::pool::Connection,
draft_id: &str,
storage: &dyn BlobStorage, storage: &dyn BlobStorage,
records: Vec<db::drafts::DraftAttachment>,
) -> Result<Vec<AttachmentData>, String> { ) -> Result<Vec<AttachmentData>, String> {
let records = db::drafts::get_draft_attachments(conn, draft_id)?;
let mut attachments = Vec::with_capacity(records.len()); let mut attachments = Vec::with_capacity(records.len());
for record in records { for record in records {
let data = storage::read_all(storage, &record.storage_key) let data = storage::read_all(storage, &record.storage_key)
+79
View File
@@ -1830,6 +1830,85 @@ pub async fn delete_message_handler(
Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
} }
/// Snoozing is local cache state only: nothing moves on the real IMAP server.
/// `wake_at` and `snooze_for_secs` are mutually exclusive ways to say when
/// the message should reappear; the client is responsible for hiding a
/// still-sleeping message from the folder it renders (see
/// [`list_sleeping_uids`]) and for showing it again once `wake_at` passes.
pub async fn snooze_message_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path((folder, uid)): Path<(String, u32)>,
Json(body): Json<SnoozeMessageRequest>,
) -> Result<Response, AppError> {
let wake_at = match (body.wake_at, body.snooze_for_secs) {
(Some(_), Some(_)) => {
return Err(AppError::BadRequest(
"Specify either wake_at or snooze_for_secs, not both".to_string(),
));
}
(Some(wake_at), None) => wake_at,
(None, Some(secs)) => {
if secs <= 0 {
return Err(AppError::BadRequest(
"snooze_for_secs must be positive".to_string(),
));
}
db::snooze::now_epoch().saturating_add(secs)
}
(None, None) => {
return Err(AppError::BadRequest(
"Specify wake_at or snooze_for_secs".to_string(),
));
}
};
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let id =
db::snooze::snooze_message(&conn, &folder, uid, wake_at).map_err(AppError::BadRequest)?;
Ok(Json(serde_json::json!({ "status": "ok", "id": id, "wake_at": wake_at })).into_response())
}
pub async fn unsnooze_message_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path((folder, uid)): Path<(String, u32)>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
db::snooze::unsnooze_message(&conn, &folder, uid).map_err(AppError::InternalError)?;
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
}
/// All snoozed messages (woken or not), for the "Snoozed" view.
pub async fn list_snoozed_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let snoozed = db::snooze::list_snoozed(&conn).map_err(AppError::InternalError)?;
Ok(Json(serde_json::json!({ "messages": snoozed })).into_response())
}
/// The UIDs in `folder` that are still snoozed as of now, so the client can
/// hide those rows from the folder it is rendering. No IMAP or background
/// work is involved; a message simply stops being returned here once its
/// wake time passes.
pub async fn list_sleeping_uids(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(folder): Path<String>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let uids = db::snooze::list_sleeping_uids_in_folder(&conn, &folder, db::snooze::now_epoch())
.map_err(AppError::InternalError)?;
Ok(Json(serde_json::json!({ "uids": uids })).into_response())
}
#[cfg(test)] #[cfg(test)]
mod sync_tests { mod sync_tests {
use super::*; use super::*;
+10
View File
@@ -42,6 +42,16 @@ pub(crate) struct CopyMessageRequest {
pub(crate) uid: u32, pub(crate) uid: u32,
} }
/// Either an absolute Unix epoch second, or a number of seconds from now.
/// Exactly one must be set.
#[derive(Deserialize)]
pub(crate) struct SnoozeMessageRequest {
#[serde(default)]
pub(crate) wake_at: Option<i64>,
#[serde(default)]
pub(crate) snooze_for_secs: Option<i64>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub(crate) struct ListMessagesResponse { pub(crate) struct ListMessagesResponse {
pub(crate) messages: Vec<MessageSummary>, pub(crate) messages: Vec<MessageSummary>,
+41 -5
View File
@@ -45,10 +45,12 @@ use tower_governor::key_extractor::KeyExtractor;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use crate::auth::csrf::csrf_protection; use crate::auth::csrf::csrf_protection;
use crate::auth::login_guard::LoginGuard;
use crate::auth::middleware::auth_guard; use crate::auth::middleware::auth_guard;
use crate::auth::session::SessionStore; use crate::auth::session::SessionStore;
use crate::config::{AppConfig, TrustedProxy}; use crate::config::{AppConfig, TrustedProxy};
use crate::imap::client::ImapClient; use crate::imap::client::ImapClient;
use crate::message_budget::MessageBuildLimiter;
use crate::realtime::events::EventBus; use crate::realtime::events::EventBus;
use crate::realtime::idle::IdleManager; use crate::realtime::idle::IdleManager;
use crate::smtp::client::SmtpClient; use crate::smtp::client::SmtpClient;
@@ -230,20 +232,38 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
} = services; } = services;
let system_diagnostics_telemetry = let system_diagnostics_telemetry =
Arc::new(system_diagnostics::SystemDiagnosticsTelemetry::new()); Arc::new(system_diagnostics::SystemDiagnosticsTelemetry::new());
let build_limiter = Arc::new(MessageBuildLimiter::new(
config.max_concurrent_message_builds,
Duration::from_secs(config.message_build_wait_secs),
));
let undo_send_registry = Arc::new(crate::undo_send::UndoSendRegistry::new());
let login_rate_limit = &config.login_rate_limit;
let login_guard = Arc::new(LoginGuard::from_config(login_rate_limit));
// Only the credential-checking endpoint is throttled; the public policy GET
// used by the login form must not consume the per-IP login budget.
let login_route = Router::new().route("/login", post(auth::login));
let login_route = if login_rate_limit.enabled {
let governor_conf = GovernorConfigBuilder::default() let governor_conf = GovernorConfigBuilder::default()
.key_extractor(ProxyAwareIpExtractor { .key_extractor(ProxyAwareIpExtractor {
trusted_proxies: config.parsed_trusted_proxies(), trusted_proxies: config.parsed_trusted_proxies(),
}) })
.period(Duration::from_secs(12)) .period(Duration::from_secs(
.burst_size(5) login_rate_limit.ip_replenish_secs.max(1),
))
.burst_size(login_rate_limit.ip_burst.max(1))
.finish() .finish()
.expect("valid governor config"); .expect("valid governor config");
login_route.layer(GovernorLayer::new(governor_conf))
} else {
tracing::warn!("login_rate_limit.enabled=false: login brute-force protection is disabled");
login_route
};
let public_auth = Router::new() let public_auth = Router::new()
.route("/login", post(auth::login))
.route("/policy", get(auth::auth_policy)) .route("/policy", get(auth::auth_policy))
.layer(middleware::from_fn(csrf_protection)) .merge(login_route)
.layer(GovernorLayer::new(governor_conf)); .layer(middleware::from_fn(csrf_protection));
let browser_routes = Router::new() let browser_routes = Router::new()
.route("/accounts", get(auth::list_accounts)) .route("/accounts", get(auth::list_accounts))
@@ -293,6 +313,10 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
post(messages::import_eml).layer(DefaultBodyLimit::max(52 * 1024 * 1024)), post(messages::import_eml).layer(DefaultBodyLimit::max(52 * 1024 * 1024)),
) )
.route("/folders/{folder}/messages", get(messages::list_messages)) .route("/folders/{folder}/messages", get(messages::list_messages))
.route(
"/folders/{name}/snoozed-uids",
get(messages::list_sleeping_uids),
)
.route( .route(
"/messages/{folder}/{uid}", "/messages/{folder}/{uid}",
get(messages::get_message).delete(messages::delete_message_handler), get(messages::get_message).delete(messages::delete_message_handler),
@@ -301,6 +325,11 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
"/messages/{folder}/{uid}/flags", "/messages/{folder}/{uid}/flags",
patch(messages::update_flags), patch(messages::update_flags),
) )
.route(
"/messages/{folder}/{uid}/snooze",
post(messages::snooze_message_handler).delete(messages::unsnooze_message_handler),
)
.route("/messages/snoozed", get(messages::list_snoozed_handler))
.route( .route(
"/messages/{folder}/{uid}/attachments/{attachment_id}", "/messages/{folder}/{uid}/attachments/{attachment_id}",
get(messages::download_attachment), get(messages::download_attachment),
@@ -316,6 +345,10 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
.route("/messages/move", post(messages::move_message_handler)) .route("/messages/move", post(messages::move_message_handler))
.route("/messages/copy", post(messages::copy_message_handler)) .route("/messages/copy", post(messages::copy_message_handler))
.route("/messages/send", post(send::send_message_handler)) .route("/messages/send", post(send::send_message_handler))
.route(
"/messages/send/{pending_send_id}",
get(send::pending_send_status).delete(send::cancel_pending_send),
)
.route( .route(
"/messages/{folder}/{uid}/unsubscribe", "/messages/{folder}/{uid}/unsubscribe",
post(unsubscribe::unsubscribe_message), post(unsubscribe::unsubscribe_message),
@@ -658,6 +691,9 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
.layer(Extension(storage)) .layer(Extension(storage))
.layer(Extension(imap_client)) .layer(Extension(imap_client))
.layer(Extension(store)) .layer(Extension(store))
.layer(Extension(login_guard))
.layer(Extension(build_limiter))
.layer(Extension(undo_send_registry))
.layer(Extension(config.clone())) .layer(Extension(config.clone()))
.layer(TraceLayer::new_for_http()); .layer(TraceLayer::new_for_http());
+211 -58
View File
@@ -1,5 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use axum::extract::Path;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{Extension, Json}; use axum::{Extension, Json};
use base64::Engine as _; use base64::Engine as _;
@@ -11,10 +13,12 @@ use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::error::AppError; use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
use crate::message_budget::{MessageBuildLimiter, draft_attachments_total, ensure_attachments_fit};
use crate::routes::drafts::{ImapDraftAttachmentRef, ImapDraftSource}; use crate::routes::drafts::{ImapDraftAttachmentRef, ImapDraftSource};
use crate::smtp::client::{AttachmentData, SendableMessage, SmtpClient, SmtpCredentials}; use crate::smtp::client::{AttachmentData, SendableMessage, SmtpClient, SmtpCredentials};
use crate::smtp::types::{PgpMode, PgpSendConfig}; use crate::smtp::types::{PgpMode, PgpSendConfig};
use crate::storage::{self, BlobStorage}; use crate::storage::{self, BlobStorage};
use crate::undo_send::{PendingOutcome, UndoSendRegistry};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SendRequest { pub struct SendRequest {
@@ -251,14 +255,25 @@ fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> {
pub struct SendResponse { pub struct SendResponse {
pub status: String, pub status: String,
pub message_id: String, pub message_id: String,
/// Set only when the message was staged under "Undo Send" instead of
/// being dispatched immediately. Cancel it with
/// `DELETE /messages/send/{pending_send_id}` or poll
/// `GET /messages/send/{pending_send_id}` for the final result.
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_send_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub undo_window_secs: Option<u64>,
} }
#[allow(clippy::too_many_arguments)]
pub async fn send_message_handler( pub async fn send_message_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(smtp_client): Extension<Arc<dyn SmtpClient>>, Extension(smtp_client): Extension<Arc<dyn SmtpClient>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Extension(storage): Extension<Arc<dyn BlobStorage>>, Extension(storage): Extension<Arc<dyn BlobStorage>>,
Extension(build_limiter): Extension<Arc<MessageBuildLimiter>>,
Extension(undo_registry): Extension<Arc<UndoSendRegistry>>,
Json(req): Json<SendRequest>, Json(req): Json<SendRequest>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let imap_source = req.imap_source.clone(); let imap_source = req.imap_source.clone();
@@ -443,17 +458,22 @@ pub async fn send_message_handler(
validate_mailbox("read receipt", &receipt_address)?; validate_mailbox("read receipt", &receipt_address)?;
} }
let mut attachments = if let Some(ref draft_id) = req.draft_id { // Reject an oversized attachment set from the stored sizes before any blob
load_draft_attachments( // is read, then bound how many requests hold attachment data in RAM at once.
&config.data_dir, // The permit lives until the message has been sent and appended to Sent.
&session.user_hash, let stored_attachments = if let Some(ref draft_id) = req.draft_id {
draft_id, draft_attachment_records(&config, &session.user_hash, draft_id)?
storage.as_ref(),
)
.await?
} else { } else {
vec![] Vec::new()
}; };
let loads_attachment_data = !stored_attachments.is_empty()
|| (imap_source.is_some() && !imap_attachment_refs.is_empty());
let build_permit = if loads_attachment_data {
Some(build_limiter.acquire().await?)
} else {
None
};
let mut attachments = read_draft_attachments(storage.as_ref(), stored_attachments).await?;
if let Some(source) = imap_source.as_ref() { if let Some(source) = imap_source.as_ref() {
let mut remote_attachments = crate::routes::drafts::load_imap_source_attachments( let mut remote_attachments = crate::routes::drafts::load_imap_source_attachments(
imap_client.as_ref(), imap_client.as_ref(),
@@ -510,6 +530,18 @@ pub async fn send_message_handler(
)); ));
} }
// Final check on what is really in memory (includes IMAP-sourced attachments,
// attached public keys and signature images) before the MIME builder copies it.
let attachment_bytes: u64 = attachments
.iter()
.map(|attachment| attachment.data.len() as u64)
.sum();
ensure_attachments_fit(
config.max_message_size_bytes(),
attachment_bytes,
attachments.len(),
)?;
let message = SendableMessage { let message = SendableMessage {
from: from_address, from: from_address,
to: req.to, to: req.to,
@@ -532,8 +564,107 @@ pub async fn send_message_handler(
max_message_size_bytes: config.max_message_size_bytes(), max_message_size_bytes: config.max_message_size_bytes(),
}; };
let imap_creds = ImapCredentials {
host: session.imap_host.clone(),
port: session.imap_port,
tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(),
password: session.password.clone(),
};
let undo_window = config.undo_send_window_secs;
if undo_window == 0 {
let message_id = dispatch_message(
&config,
smtp_client.as_ref(),
&smtp_creds,
&message,
imap_client.as_ref(),
&imap_creds,
&session.user_hash,
req.draft_id.as_deref(),
imap_source.as_ref(),
storage.as_ref(),
)
.await?;
return Ok(Json(SendResponse {
status: "sent".to_string(),
message_id,
pending_send_id: None,
undo_window_secs: None,
})
.into_response());
}
// Undo Send: stage the already-built message and let the client cancel
// it for `undo_window` seconds before anything is actually dispatched.
// The MessageBuildLimiter permit (if any) is moved into the delayed task
// below and held for the whole window, exactly as it is already held
// through an immediate send; this is what bounds how many undo-pending
// sends can hold attachment data in memory at once.
let pending_id = undo_registry.stage();
let draft_id = req.draft_id.clone();
let dispatch_config = config.clone();
let dispatch_smtp_client = smtp_client.clone();
let dispatch_imap_client = imap_client.clone();
let dispatch_storage = storage.clone();
let dispatch_registry = undo_registry.clone();
let dispatch_user_hash = session.user_hash.clone();
let dispatch_pending_id = pending_id.clone();
tokio::spawn(async move {
let _permit = build_permit;
tokio::time::sleep(Duration::from_secs(undo_window)).await;
if !dispatch_registry.begin_dispatch(&dispatch_pending_id) {
// Cancelled during the undo window: never contact SMTP.
return;
}
let result = dispatch_message(
&dispatch_config,
dispatch_smtp_client.as_ref(),
&smtp_creds,
&message,
dispatch_imap_client.as_ref(),
&imap_creds,
&dispatch_user_hash,
draft_id.as_deref(),
imap_source.as_ref(),
dispatch_storage.as_ref(),
)
.await;
match result {
Ok(message_id) => dispatch_registry.record_sent(&dispatch_pending_id, message_id),
Err(error) => dispatch_registry.record_failed(&dispatch_pending_id, error.to_string()),
}
});
Ok(Json(SendResponse {
status: "pending".to_string(),
message_id: String::new(),
pending_send_id: Some(pending_id),
undo_window_secs: Some(undo_window),
})
.into_response())
}
/// Sends the already-built `message` over SMTP, appends it to the Sent
/// folder, and cleans up the source draft/IMAP snapshot it came from. Shared
/// by an immediate send and the delayed dispatch after an Undo Send window.
#[allow(clippy::too_many_arguments)]
async fn dispatch_message(
config: &AppConfig,
smtp_client: &dyn SmtpClient,
smtp_creds: &SmtpCredentials,
message: &SendableMessage,
imap_client: &dyn ImapClient,
imap_creds: &ImapCredentials,
user_hash: &str,
draft_id: Option<&str>,
imap_source: Option<&ImapDraftSource>,
storage: &dyn BlobStorage,
) -> Result<String, AppError> {
let sent = smtp_client let sent = smtp_client
.send_message(&smtp_creds, &message) .send_message(smtp_creds, message)
.await .await
.map_err(|e| match e { .map_err(|e| match e {
crate::smtp::client::SmtpError::MessageTooLarge { actual, limit } => { crate::smtp::client::SmtpError::MessageTooLarge { actual, limit } => {
@@ -547,17 +678,8 @@ pub async fn send_message_handler(
})?; })?;
let message_id = sent.message_id.clone(); let message_id = sent.message_id.clone();
let imap_creds = ImapCredentials {
host: session.imap_host.clone(),
port: session.imap_port,
tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(),
password: session.password.clone(),
};
{ {
let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); let database = db::pool::UserDb::new(&config.data_dir, user_hash);
let sent_folder = database let sent_folder = database
.run(|conn| { .run(|conn| {
Ok(db::special_folders::get_role(conn, "sent")? Ok(db::special_folders::get_role(conn, "sent")?
@@ -568,7 +690,7 @@ pub async fn send_message_handler(
.unwrap_or_else(|_| "Sent".to_string()); .unwrap_or_else(|_| "Sent".to_string());
match imap_client match imap_client
.append_message(&imap_creds, &sent_folder, &sent.rfc822, &["\\Seen"]) .append_message(imap_creds, &sent_folder, &sent.rfc822, &["\\Seen"])
.await .await
{ {
Ok(()) => { Ok(()) => {
@@ -587,62 +709,93 @@ pub async fn send_message_handler(
} }
let mut source_removed_with_local_draft = false; let mut source_removed_with_local_draft = false;
if let Some(ref draft_id) = req.draft_id { if let Some(draft_id) = draft_id {
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash) if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
&& let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id) && let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id)
{ {
source_removed_with_local_draft = imap_source.as_ref().is_some_and(|source| { source_removed_with_local_draft = imap_source.is_some_and(|source| {
draft.remote_folder.as_deref() == Some(source.folder.as_str()) draft.remote_folder.as_deref() == Some(source.folder.as_str())
&& draft.remote_uid == Some(source.uid) && draft.remote_uid == Some(source.uid)
&& draft.remote_uid_validity == Some(source.uid_validity) && draft.remote_uid_validity == Some(source.uid_validity)
}); });
crate::routes::drafts::remove_remote_draft_snapshot( crate::routes::drafts::remove_remote_draft_snapshot(imap_client, imap_creds, &draft)
imap_client.as_ref(),
&imap_creds,
&draft,
)
.await; .await;
} }
cleanup_draft( cleanup_draft(&config.data_dir, user_hash, draft_id, storage).await;
&config.data_dir,
&session.user_hash,
draft_id,
storage.as_ref(),
)
.await;
} }
if !source_removed_with_local_draft && let Some(source) = imap_source.as_ref() { if !source_removed_with_local_draft && let Some(source) = imap_source {
crate::routes::drafts::remove_imap_source_snapshot( crate::routes::drafts::remove_imap_source_snapshot(imap_client, imap_creds, source, "", 0)
imap_client.as_ref(),
&imap_creds,
source,
"",
0,
)
.await; .await;
} }
Ok(Json(SendResponse { Ok(message_id)
status: "sent".to_string(),
message_id,
})
.into_response())
} }
async fn load_draft_attachments( /// `DELETE /messages/send/{pending_send_id}`: cancels a message still inside
data_dir: &str, /// its Undo Send window. `404` if the id is unknown/expired, `409` if it
/// already started dispatching (or finished) and is too late to cancel.
pub async fn cancel_pending_send(
Extension(undo_registry): Extension<Arc<UndoSendRegistry>>,
Path(pending_id): Path<String>,
) -> Result<Response, AppError> {
match undo_registry.status(&pending_id) {
None => Err(AppError::NotFound(
"No pending send with that id".to_string(),
)),
Some(PendingOutcome::Pending) => {
if undo_registry.cancel(&pending_id) {
Ok(Json(serde_json::json!({ "status": "cancelled" })).into_response())
} else {
Err(AppError::BadRequest(
"Too late to cancel; the message is already being sent".to_string(),
))
}
}
Some(_) => Err(AppError::BadRequest(
"Too late to cancel; the message is already being sent".to_string(),
)),
}
}
/// `GET /messages/send/{pending_send_id}`: the current state of a staged
/// send, for the client to poll once its Undo Send countdown ends.
pub async fn pending_send_status(
Extension(undo_registry): Extension<Arc<UndoSendRegistry>>,
Path(pending_id): Path<String>,
) -> Result<Response, AppError> {
match undo_registry.status(&pending_id) {
Some(outcome) => Ok(Json(outcome).into_response()),
None => Err(AppError::NotFound(
"No pending send with that id".to_string(),
)),
}
}
/// Draft attachment metadata, rejected with 413 when the stored sizes alone
/// already exceed the message size limit. No blob data is read here.
fn draft_attachment_records(
config: &AppConfig,
user_hash: &str, user_hash: &str,
draft_id: &str, draft_id: &str,
storage: &dyn BlobStorage, ) -> Result<Vec<db::drafts::DraftAttachment>, AppError> {
) -> Result<Vec<AttachmentData>, AppError> { let conn = db::pool::open_user_db(&config.data_dir, user_hash)
let conn = db::pool::open_user_db(data_dir, user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let records =
let db_attachments =
db::drafts::get_draft_attachments(&conn, draft_id).map_err(AppError::InternalError)?; db::drafts::get_draft_attachments(&conn, draft_id).map_err(AppError::InternalError)?;
ensure_attachments_fit(
config.max_message_size_bytes(),
draft_attachments_total(&records),
records.len(),
)?;
Ok(records)
}
let mut attachments = Vec::with_capacity(db_attachments.len()); async fn read_draft_attachments(
for att in db_attachments { storage: &dyn BlobStorage,
records: Vec<db::drafts::DraftAttachment>,
) -> Result<Vec<AttachmentData>, AppError> {
let mut attachments = Vec::with_capacity(records.len());
for att in records {
let data = storage::read_all(storage, &att.storage_key) let data = storage::read_all(storage, &att.storage_key)
.await .await
.map_err(|e| { .map_err(|e| {
+5 -1
View File
@@ -189,7 +189,11 @@ fn trusted_contains(trusted: &[TrustedProxy], ip: &IpAddr) -> bool {
trusted.iter().any(|entry| entry.contains(ip)) trusted.iter().any(|entry| entry.contains(ip))
} }
fn effective_client_ip(config: &AppConfig, headers: &HeaderMap, peer_ip: IpAddr) -> IpAddr { pub(crate) fn effective_client_ip(
config: &AppConfig,
headers: &HeaderMap,
peer_ip: IpAddr,
) -> IpAddr {
let trusted = config.parsed_trusted_proxies(); let trusted = config.parsed_trusted_proxies();
if !trusted_contains(&trusted, &peer_ip) { if !trusted_contains(&trusted, &peer_ip) {
return peer_ip; return peer_ip;
+257
View File
@@ -86,6 +86,12 @@ fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
sieve_timeout_secs: 10, sieve_timeout_secs: 10,
trusted_proxies: None, trusted_proxies: None,
outgoing_headers: Vec::new(), outgoing_headers: Vec::new(),
// Existing send tests assert on the immediate, synchronous send
// outcome (status/body), not the "Undo Send" staged-dispatch flow;
// keep that behavior disabled here so they keep testing what they
// were written to test. undo_send.rs/routes/send.rs have their own
// dedicated tests for the staged-dispatch path.
undo_send_window_secs: 0,
..AppConfig::default() ..AppConfig::default()
}) })
} }
@@ -2720,3 +2726,254 @@ async fn export_contact_strips_control_chars_from_filename() {
assert!(!cd.contains('\n')); assert!(!cd.contains('\n'));
} }
} }
fn multipart_upload_request(
uri: &str,
filename: &str,
payload: &[u8],
auth: &[(&'static str, String)],
) -> Request<Body> {
let boundary = "rustbox-test-boundary";
let mut body = Vec::new();
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
)
.as_bytes(),
);
body.extend_from_slice(payload);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
let mut req = Request::builder()
.method("POST")
.uri(uri)
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.header("x-requested-with", "XMLHttpRequest");
for (name, value) in auth {
req = req.header(*name, value.as_str());
}
req.body(Body::from(body)).unwrap()
}
async fn error_code_of(response: axum::response::Response) -> String {
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
json["error"]["code"]
.as_str()
.unwrap_or_default()
.to_string()
}
#[tokio::test]
async fn upload_over_the_message_size_limit_is_rejected_while_streaming() {
let static_dir = setup_static_dir();
let data_dir = TempDir::new().unwrap();
let mut cfg = (*test_config_with_imap(
static_dir.path().to_str().unwrap(),
data_dir.path().to_str().unwrap(),
))
.clone();
cfg.max_attachment_size_mb = 10;
cfg.max_message_size_mb = 1;
let store = test_store();
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
let app = create_router(
Arc::new(cfg),
store,
Arc::new(MockImapClient::new()),
test_smtp_client(),
test_search_engine(data_dir.path().to_str().unwrap()),
test_event_bus(),
test_idle_manager(),
);
let auth = auth_headers(&browser_id, &account_id, &token);
// A single 2 MiB file cannot fit a 1 MiB message even though it is below
// the per-attachment limit.
let response = app
.clone()
.oneshot(multipart_upload_request(
"/api/drafts/limit-draft/attachments",
"big.bin",
&vec![7u8; 2 * 1024 * 1024],
&auth,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(error_code_of(response).await, "PAYLOAD_TOO_LARGE");
// Two files that each fit but not together: the second one is refused.
for (index, expected) in [(1, StatusCode::OK), (2, StatusCode::PAYLOAD_TOO_LARGE)] {
let response = app
.clone()
.oneshot(multipart_upload_request(
"/api/drafts/limit-draft-2/attachments",
&format!("part-{index}.bin"),
&vec![1u8; 500_000],
&auth,
))
.await
.unwrap();
assert_eq!(response.status(), expected, "upload #{index}");
}
let conn =
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
let stored = crate::db::drafts::get_draft_attachments(&conn, "limit-draft-2").unwrap();
assert_eq!(stored.len(), 1);
assert!(
crate::db::drafts::get_draft_attachments(&conn, "limit-draft")
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn send_rejects_oversized_draft_attachments_before_reading_any_blob() {
let static_dir = setup_static_dir();
let data_dir = TempDir::new().unwrap();
let config = test_config_with_smtp(
static_dir.path().to_str().unwrap(),
data_dir.path().to_str().unwrap(),
);
let store = test_store();
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
let conn =
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
crate::db::drafts::upsert_draft(
&conn,
"huge-draft",
"",
"",
"",
"",
"",
None,
None,
None,
false,
"normal",
"html",
"none",
false,
false,
false,
)
.unwrap();
// The recorded size is far above max_message_size_mb and the blob does not
// exist: a 413 (not a 500 read error) proves nothing was loaded.
crate::db::drafts::add_draft_attachment(
&conn,
&crate::db::drafts::NewDraftAttachment {
id: "huge-att",
draft_id: "huge-draft",
filename: "huge.bin",
content_type: "application/octet-stream",
size: 200 * 1024 * 1024,
storage_key: "missing/blob",
checksum_sha256: None,
},
)
.unwrap();
let mock_smtp: Arc<dyn SmtpClient> = Arc::new(MockSmtpClient::new());
let app = create_router(
config,
store,
Arc::new(MockImapClient::new()),
mock_smtp,
test_search_engine(data_dir.path().to_str().unwrap()),
test_event_bus(),
test_idle_manager(),
);
let mut req = Request::builder()
.method("POST")
.uri("/api/messages/send")
.header("x-requested-with", "XMLHttpRequest")
.header("content-type", "application/json");
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
req = req.header(name, value);
}
let response = app
.oneshot(
req.body(Body::from(
r#"{"to":["bob@example.com"],"subject":"Big","text_body":"x","draft_id":"huge-draft"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(error_code_of(response).await, "PAYLOAD_TOO_LARGE");
}
#[tokio::test]
async fn sends_with_attachments_release_their_build_slot() {
let static_dir = setup_static_dir();
let data_dir = TempDir::new().unwrap();
let mut cfg = (*test_config_with_smtp(
static_dir.path().to_str().unwrap(),
data_dir.path().to_str().unwrap(),
))
.clone();
// A single slot and a short wait: the second send only succeeds when the
// first one gave its permit back.
cfg.max_concurrent_message_builds = 1;
cfg.message_build_wait_secs = 1;
let store = test_store();
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
let mock_imap = Arc::new(MockImapClient::new());
let app = create_router(
Arc::new(cfg),
store,
mock_imap.clone(),
Arc::new(MockSmtpClient::new()),
test_search_engine(data_dir.path().to_str().unwrap()),
test_event_bus(),
test_idle_manager(),
);
let auth = auth_headers(&browser_id, &account_id, &token);
for draft_id in ["slot-draft-1", "slot-draft-2"] {
let upload = app
.clone()
.oneshot(multipart_upload_request(
&format!("/api/drafts/{draft_id}/attachments"),
"note.txt",
b"hello attachment",
&auth,
))
.await
.unwrap();
assert_eq!(upload.status(), StatusCode::OK);
let mut req = Request::builder()
.method("POST")
.uri("/api/messages/send")
.header("x-requested-with", "XMLHttpRequest")
.header("content-type", "application/json");
for (name, value) in &auth {
req = req.header(*name, value.as_str());
}
let response = app
.clone()
.oneshot(
req.body(Body::from(format!(
r#"{{"to":["bob@example.com"],"subject":"With file","text_body":"see attached","draft_id":"{draft_id}"}}"#
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "send for {draft_id}");
}
assert_eq!(mock_imap.appended.lock().unwrap().len(), 2);
}
+194
View File
@@ -0,0 +1,194 @@
//! In-memory staging for "Undo Send".
//!
//! When `undo_send_window_secs > 0`, a fully built outgoing message is held
//! in memory for that many seconds before it is actually dispatched to SMTP,
//! so the sender can cancel it. Nothing here is persisted: a server restart
//! during the window loses any message still staged, the same way a message
//! already queued by any mail server can be lost if the process is killed
//! mid-flight. The window is meant to be short (seconds, not minutes) for
//! exactly this reason.
//!
//! State transitions are `Pending -> Cancelled` (a user clicks "Undo") or
//! `Pending -> Dispatching -> Sent | Failed` (the window elapses). Both the
//! cancel handler and the delayed dispatch task race to leave `Pending`, but
//! only one of them can win because the transition happens under a single
//! mutex: whichever reaches it first decides the outcome.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
/// How long a finished (sent/cancelled/failed) entry is kept around so a late
/// status poll from the client still finds it.
const RESULT_RETENTION_SECS: u64 = 120;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum PendingOutcome {
Pending,
Dispatching,
Sent { message_id: String },
Cancelled,
Failed { error: String },
}
struct PendingEntry {
outcome: PendingOutcome,
recorded_at: u64,
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Default)]
pub struct UndoSendRegistry {
entries: Mutex<HashMap<String, PendingEntry>>,
}
impl UndoSendRegistry {
pub fn new() -> Self {
Self::default()
}
fn sweep(entries: &mut HashMap<String, PendingEntry>) {
let now = now_epoch();
entries.retain(|_, entry| {
matches!(
entry.outcome,
PendingOutcome::Pending | PendingOutcome::Dispatching
) || now.saturating_sub(entry.recorded_at) < RESULT_RETENTION_SECS
});
}
/// Registers a new pending send and returns its id.
pub fn stage(&self) -> String {
let id = uuid::Uuid::new_v4().to_string();
let mut entries = self.entries.lock().expect("undo-send registry lock");
Self::sweep(&mut entries);
entries.insert(
id.clone(),
PendingEntry {
outcome: PendingOutcome::Pending,
recorded_at: now_epoch(),
},
);
id
}
/// Cancels a still-pending send. Returns `true` if this call is the one
/// that cancelled it; `false` if it was already dispatching/finished or
/// the id is unknown/expired.
pub fn cancel(&self, id: &str) -> bool {
let mut entries = self.entries.lock().expect("undo-send registry lock");
match entries.get_mut(id) {
Some(entry) if entry.outcome == PendingOutcome::Pending => {
entry.outcome = PendingOutcome::Cancelled;
entry.recorded_at = now_epoch();
true
}
_ => false,
}
}
/// Called by the delayed dispatch task once its wait is over, right
/// before it would contact SMTP. Returns `true` if this call is the one
/// that claimed the send (i.e. it was not cancelled in the meantime);
/// the caller must then actually dispatch and record the result.
pub fn begin_dispatch(&self, id: &str) -> bool {
let mut entries = self.entries.lock().expect("undo-send registry lock");
match entries.get_mut(id) {
Some(entry) if entry.outcome == PendingOutcome::Pending => {
entry.outcome = PendingOutcome::Dispatching;
true
}
_ => false,
}
}
pub fn record_sent(&self, id: &str, message_id: String) {
let mut entries = self.entries.lock().expect("undo-send registry lock");
if let Some(entry) = entries.get_mut(id) {
entry.outcome = PendingOutcome::Sent { message_id };
entry.recorded_at = now_epoch();
}
}
pub fn record_failed(&self, id: &str, error: String) {
let mut entries = self.entries.lock().expect("undo-send registry lock");
if let Some(entry) = entries.get_mut(id) {
entry.outcome = PendingOutcome::Failed { error };
entry.recorded_at = now_epoch();
}
}
pub fn status(&self, id: &str) -> Option<PendingOutcome> {
let mut entries = self.entries.lock().expect("undo-send registry lock");
Self::sweep(&mut entries);
entries.get(id).map(|entry| entry.outcome.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pending_send_can_be_cancelled_exactly_once() {
let registry = UndoSendRegistry::new();
let id = registry.stage();
assert_eq!(registry.status(&id), Some(PendingOutcome::Pending));
assert!(registry.cancel(&id));
assert_eq!(registry.status(&id), Some(PendingOutcome::Cancelled));
// A second cancel is a no-op: the first one already won the race.
assert!(!registry.cancel(&id));
}
#[test]
fn dispatch_cannot_start_once_cancelled() {
let registry = UndoSendRegistry::new();
let id = registry.stage();
assert!(registry.cancel(&id));
assert!(!registry.begin_dispatch(&id));
}
#[test]
fn cancel_cannot_win_once_dispatch_has_begun() {
let registry = UndoSendRegistry::new();
let id = registry.stage();
assert!(registry.begin_dispatch(&id));
assert!(!registry.cancel(&id));
registry.record_sent(&id, "msg-1".to_string());
assert_eq!(
registry.status(&id),
Some(PendingOutcome::Sent {
message_id: "msg-1".to_string()
})
);
}
#[test]
fn a_dispatch_that_fails_is_recorded_as_failed() {
let registry = UndoSendRegistry::new();
let id = registry.stage();
assert!(registry.begin_dispatch(&id));
registry.record_failed(&id, "SMTP timed out".to_string());
assert_eq!(
registry.status(&id),
Some(PendingOutcome::Failed {
error: "SMTP timed out".to_string()
})
);
}
#[test]
fn an_unknown_id_has_no_status() {
let registry = UndoSendRegistry::new();
assert_eq!(registry.status("does-not-exist"), None);
assert!(!registry.cancel("does-not-exist"));
assert!(!registry.begin_dispatch("does-not-exist"));
}
}
+38
View File
@@ -364,6 +364,44 @@
width: 100%; width: 100%;
} }
.snoozed-message-list {
display: grid;
gap: 8px;
}
.snoozed-message-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border: 1px solid var(--border, #e2e2e2);
border-radius: 8px;
}
.snoozed-message-info {
display: grid;
gap: 2px;
min-width: 0;
}
.snoozed-message-info strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.snoozed-message-info span {
color: var(--muted);
font-size: 0.85em;
}
.snoozed-message-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.app-prompt-actions> :first-child { .app-prompt-actions> :first-child {
margin-right: auto; margin-right: auto;
} }
+58 -6
View File
@@ -98,6 +98,7 @@ async function loadMessages(reset = false, options = {}) {
} }
$("#connection-label").textContent = "Connected"; $("#connection-label").textContent = "Connected";
renderMessages(); renderMessages();
void applySnoozeHiding();
const desiredScroll = savedState?.scrollTop ?? previousScrollTop; const desiredScroll = savedState?.scrollTop ?? previousScrollTop;
if (mode === "pagination") { if (mode === "pagination") {
requestAnimationFrame(() => { list.scrollTop = 0; }); requestAnimationFrame(() => { list.scrollTop = 0; });
@@ -302,6 +303,24 @@ async function quickUpdateMessageFlag(message, flag, add, successMessage) {
} }
} }
// Hides rows for messages that are currently snoozed in this folder. This is
// a purely visual, best-effort filter on top of the already-rendered list:
// it never changes state.messages/state.total, so pagination/counts/caching
// above are completely unaffected. A message simply stops being hidden once
// its wake time passes and the folder is reloaded or reopened.
async function applySnoozeHiding() {
if (state.searchContext) return;
const folder = state.folder;
try {
const data = await request(`/folders/${encodeURIComponent(folder)}/snoozed-uids`, { method: "GET" });
const sleeping = new Set((data.uids || []).map(String));
if (!sleeping.size || folder !== state.folder) return;
for (const row of $$(".message-row", $("#message-list"))) {
if (sleeping.has(row.dataset.uid)) row.style.display = "none";
}
} catch (_) { /* best-effort visual filter only; never blocks the folder view */ }
}
function renderMessages() { function renderMessages() {
$("#folder-title").textContent = state.folder; $("#folder-title").textContent = state.folder;
const folderTotal = Number(state.folders.find((folder) => folder.name === state.folder)?.total_count); const folderTotal = Number(state.folders.find((folder) => folder.name === state.folder)?.total_count);
@@ -401,12 +420,24 @@ function renderMessages() {
}); });
row.addEventListener("dragend", () => row.classList.remove("dragging")); row.addEventListener("dragend", () => row.classList.remove("dragging"));
row.addEventListener("click", (event) => { row.addEventListener("click", (event) => {
if (state.selectionMode) {
if (event.shiftKey) { if (event.shiftKey) {
event.preventDefault(); event.preventDefault();
selectMessageRange(message); selectMessageRange(message);
return; return;
} }
if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); return setMessageSelected(message, !state.selectedMessages.has(messageKey(message)));
}
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
openMessageInNewTab(folder, message.uid);
return;
}
if (event.shiftKey) {
event.preventDefault();
openMessageInPopup(folder, message.uid);
return;
}
setMessageSelectionAnchor(message); setMessageSelectionAnchor(message);
openMessage(folder, message.uid); openMessage(folder, message.uid);
}); });
@@ -455,16 +486,37 @@ async function senderIsSavedContact(email) {
} }
function readerFullscreenUrl(detail = state.currentDetail) { function messageDeepLinkUrl(folder, uid) {
const url = new URL(location.href); const url = new URL(location.href);
if (detail?.folder && Number.isFinite(Number(detail.uid))) { url.searchParams.set("mail_folder", folder);
url.searchParams.set("mail_folder", detail.folder); url.searchParams.set("mail_uid", String(uid));
url.searchParams.set("mail_uid", String(detail.uid));
url.searchParams.set("mail_fullscreen", "1"); url.searchParams.set("mail_fullscreen", "1");
}
return url; return url;
} }
function readerFullscreenUrl(detail = state.currentDetail) {
if (detail?.folder && Number.isFinite(Number(detail.uid))) return messageDeepLinkUrl(detail.folder, detail.uid);
return new URL(location.href);
}
// Gmail-style modifier clicks on a message row, active only outside selection
// mode (inside selection mode the same modifiers keep doing range/toggle
// selection, see the row click handler below).
function openMessageInNewTab(folder, uid) {
window.open(messageDeepLinkUrl(folder, uid), "_blank", "noopener");
}
function openMessageInPopup(folder, uid) {
const width = Math.max(420, Math.min(900, Math.round(innerWidth * 0.8)));
const height = Math.max(360, Math.min(760, Math.round(innerHeight * 0.85)));
const left = Math.max(0, Math.round((innerWidth - width) / 2) + (window.screenX || 0));
const top = Math.max(0, Math.round((innerHeight - height) / 2) + (window.screenY || 0));
const features = `popup=1,noopener,width=${width},height=${height},left=${left},top=${top}`;
// A stable window name per message re-focuses the same popup on a second
// shift-click instead of spawning duplicates, matching normal browser behavior.
window.open(messageDeepLinkUrl(folder, uid), `rustbox-message-${folder}-${uid}`, features);
}
function restoreReaderScroll() { function restoreReaderScroll() {
const reader = $("#reader-content"); const reader = $("#reader-content");
if (!reader) return; if (!reader) return;
+31 -1
View File
@@ -1788,6 +1788,32 @@ function buildReferences(detail) {
return values.length ? values.join(" ") : null; return values.length ? values.join(" ") : null;
} }
// "Undo Send": the server held the just-sent message back for a few seconds
// instead of dispatching it immediately. Show a toast with an Undo action
// for the whole window, then poll once for the final result so a send that
// actually failed in the background is still reported.
function notifyUndoSendStaged(pendingId, windowSecs) {
const seconds = Math.max(0, windowSecs);
toast("Sending\u2026", "info", seconds * 1000 || 4000, {
label: "Undo",
onClick: async () => {
try {
await request(`/messages/send/${encodeURIComponent(pendingId)}`, { method: "DELETE" });
toast("Send cancelled", "success");
} catch (error) {
toast(error?.message || "Too late to cancel", "warning");
}
},
});
setTimeout(async () => {
try {
const final = await request(`/messages/send/${encodeURIComponent(pendingId)}`);
if (final?.status === "sent") toast("Message sent", "success");
else if (final?.status === "failed") toast(`Could not send: ${final.error || "unknown error"}`, "error");
} catch (_) { /* already swept or still dispatching; nothing useful to report */ }
}, seconds * 1000 + 1500);
}
async function sendMessage(event) { async function sendMessage(event) {
event.preventDefault(); event.preventDefault();
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning"); if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
@@ -1821,7 +1847,7 @@ async function sendMessage(event) {
setBusy(send, true); setBusy(send, true);
const editor = $("#compose-editor"); const editor = $("#compose-editor");
try { try {
await request("/messages/send", { const sendResult = await request("/messages/send", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
to: parseRecipients($("#compose-to").value), to: parseRecipients($("#compose-to").value),
@@ -1865,7 +1891,11 @@ async function sendMessage(event) {
state.pgpRecipientStatus = []; state.pgpRecipientStatus = [];
$("#compose-pgp-passphrase").value = ""; $("#compose-pgp-passphrase").value = "";
updateComposeMessageSize(); updateComposeMessageSize();
if (sendResult?.status === "pending" && sendResult.pending_send_id) {
notifyUndoSendStaged(sendResult.pending_send_id, Number(sendResult.undo_window_secs) || 0);
} else {
toast("Message sent", "success"); toast("Message sent", "success");
}
await loadFolders(); await loadFolders();
} catch (error) { } catch (error) {
state.composeDirty = true; state.composeDirty = true;
+127
View File
@@ -181,6 +181,131 @@ async function copyCurrentTo(targetFolder) {
} catch (error) { toast(error.message, "error"); } } catch (error) { toast(error.message, "error"); }
} }
// Snoozing hides a message from its folder listing until a chosen wake time.
// It is purely local client/server cache state: nothing moves on the real
// IMAP server, so a snooze never risks the actual mailbox.
function snoozePresetOptions() {
const now = new Date();
const laterToday = new Date(now.getTime() + 3 * 60 * 60 * 1000);
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
const nextWeek = new Date(now);
nextWeek.setDate(nextWeek.getDate() + (8 - nextWeek.getDay()) % 7 || 7);
nextWeek.setHours(9, 0, 0, 0);
const weekend = new Date(now);
const daysToSaturday = (6 - weekend.getDay() + 7) % 7 || 7;
weekend.setDate(weekend.getDate() + daysToSaturday);
weekend.setHours(9, 0, 0, 0);
return [
{ label: `Later today (${formatDate(laterToday.toISOString(), true)})`, value: String(Math.floor(laterToday.getTime() / 1000)) },
{ label: `Tomorrow (${formatDate(tomorrow.toISOString(), true)})`, value: String(Math.floor(tomorrow.getTime() / 1000)) },
{ label: `This weekend (${formatDate(weekend.toISOString(), true)})`, value: String(Math.floor(weekend.getTime() / 1000)) },
{ label: `Next week (${formatDate(nextWeek.toISOString(), true)})`, value: String(Math.floor(nextWeek.getTime() / 1000)) },
];
}
async function promptSnoozeCurrent() {
if (!state.currentDetail) return;
const wakeAt = await appDialog({
title: "Snooze until\u2026",
selectOptions: snoozePresetOptions(),
confirmLabel: "Snooze",
});
if (!wakeAt) return;
await snoozeCurrent(Number(wakeAt));
}
async function snoozeCurrent(wakeAtEpochSeconds) {
if (!state.currentDetail || !Number.isFinite(wakeAtEpochSeconds)) return;
const detail = state.currentDetail;
const nextTarget = nextMessageTargetAfterRemoval(detail.folder, detail.uid);
try {
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/snooze`, {
method: "POST",
body: JSON.stringify({ wake_at: wakeAtEpochSeconds }),
});
await removeCurrentMessageFromView(detail.folder, detail.uid, nextTarget);
await refreshFolderListOnly();
toast(`Snoozed until ${formatDate(new Date(wakeAtEpochSeconds * 1000).toISOString(), true)}`, "success");
} catch (error) {
toast(error.message, "error");
}
}
async function showSnoozedMessages() {
let data;
try {
data = await request("/messages/snoozed", { method: "GET" });
} catch (error) {
toast(error.message, "error");
return;
}
const items = data.messages || [];
const dialog = el("dialog", "dialog app-prompt-dialog");
const form = el("div", "dialog-card app-prompt-card");
const header = el("header", "dialog-header");
header.append(el("strong", "", "Snoozed messages"));
const close = el("button", "icon-button");
close.type = "button";
close.setAttribute("aria-label", "Close");
close.innerHTML = svgIcon("x");
close.addEventListener("click", () => dialog.close());
header.append(close);
const body = el("div", "app-prompt-body");
if (!items.length) {
body.append(el("p", "app-prompt-message", "No snoozed messages."));
} else {
const list = el("div", "snoozed-message-list");
for (const item of items) {
const row = el("div", "snoozed-message-row");
const info = el("div", "snoozed-message-info");
info.append(
el("strong", "", item.subject || "(no subject)"),
el("span", "", item.from_name || item.from_address || ""),
el("span", "", `Wakes ${formatDate(new Date(item.wake_at * 1000).toISOString(), true)}`),
);
const actions = el("div", "snoozed-message-actions");
const openBtn = el("button", "button quiet", "Open");
openBtn.type = "button";
openBtn.addEventListener("click", async () => { dialog.close(); await openMessage(item.message_folder, item.message_uid); });
const wakeBtn = el("button", "button quiet", "Unsnooze now");
wakeBtn.type = "button";
wakeBtn.addEventListener("click", async () => {
if (await unsnoozeMessage(item.message_folder, item.message_uid)) {
row.remove();
if (state.folder === item.message_folder) await refreshFolderListOnly();
}
});
actions.append(openBtn, wakeBtn);
row.append(info, actions);
list.append(row);
}
body.append(list);
}
const actions = el("footer", "dialog-actions app-prompt-actions");
const okBtn = el("button", "button primary", "Close");
okBtn.type = "button";
okBtn.addEventListener("click", () => dialog.close());
actions.append(okBtn);
form.append(header, body, actions);
dialog.append(form);
dialog.addEventListener("close", () => dialog.remove());
document.body.append(dialog);
dialog.showModal();
}
async function unsnoozeMessage(folder, uid) {
try {
await request(`/messages/${encodeURIComponent(folder)}/${uid}/snooze`, { method: "DELETE" });
toast("Message unsnoozed", "success");
return true;
} catch (error) {
toast(error.message, "error");
return false;
}
}
const SPAM_DEFAULT_FOLDER_KEY = "rustbox.defaultSpamFolder"; const SPAM_DEFAULT_FOLDER_KEY = "rustbox.defaultSpamFolder";
function configuredBrowserSpamFolder() { function configuredBrowserSpamFolder() {
@@ -602,6 +727,8 @@ function openMessageContextMenu(x, y, message) {
{ label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } }, { label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } },
"separator", "separator",
{ label: "Archive", action: async () => { await ensureMessageOpen(message); await archiveCurrent(); } }, { label: "Archive", action: async () => { await ensureMessageOpen(message); await archiveCurrent(); } },
{ label: "Snooze\u2026", action: async () => { await ensureMessageOpen(message); await promptSnoozeCurrent(); } },
{ label: "View snoozed messages\u2026", action: async () => { await showSnoozedMessages(); } },
{ label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", message.folder || state.folder); if (target) await moveCurrentTo(target); } }, { label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", message.folder || state.folder); if (target) await moveCurrentTo(target); } },
{ label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", message.folder || state.folder); if (target) await copyCurrentTo(target); } }, { label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", message.folder || state.folder); if (target) await copyCurrentTo(target); } },
{ label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } }, { label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } },
+2
View File
@@ -678,6 +678,7 @@ function bindEvents() {
const globalKey = event.key.toLowerCase(); const globalKey = event.key.toLowerCase();
if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; } if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; }
if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; } if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; }
if (globalKey === "b" && event.shiftKey) { event.preventDefault(); showSnoozedMessages(); return; }
} }
if (appShortcut && state.currentDetail) { if (appShortcut && state.currentDetail) {
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
@@ -691,6 +692,7 @@ function bindEvents() {
else if (key === "a") { event.preventDefault(); openCompose("reply-all"); } else if (key === "a") { event.preventDefault(); openCompose("reply-all"); }
else if (key === "f") { event.preventDefault(); openCompose("forward"); } else if (key === "f") { event.preventDefault(); openCompose("forward"); }
else if (key === "e") { event.preventDefault(); archiveCurrent(); } else if (key === "e") { event.preventDefault(); archiveCurrent(); }
else if (key === "b") { event.preventDefault(); promptSnoozeCurrent(); }
else if (key === "m") { event.preventDefault(); toggleUnread(); } else if (key === "m") { event.preventDefault(); toggleUnread(); }
else if (key === "s") { event.preventDefault(); toggleFlagged(); } else if (key === "s") { event.preventDefault(); toggleFlagged(); }
else if (event.key === "!") { event.preventDefault(); junkCurrent(); } else if (event.key === "!") { event.preventDefault(); junkCurrent(); }