From 3b9110c8c83bf4edefefe34a059d6a065b62cc71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 4 Nov 2026 14:59:03 +0100 Subject: [PATCH] v0.2.22 --- CHANGELOG.md | 15 + Cargo.lock | 2 +- Cargo.toml | 2 +- NOTICE.md | 2 +- README.md | 17 +- RELEASE-MANIFEST.txt | 21 +- RELEASE-VALIDATION.txt | 22 +- config.dev.toml | 13 + config.example.toml | 52 +++ docs/IMPLEMENTATION_STATUS.md | 18 +- migrations/README.md | 8 +- migrations/mysql/V052__snooze.sql | 9 + migrations/mysql/schema.sql | 12 +- migrations/pgsql/V052__snooze.sql | 10 + migrations/pgsql/schema.sql | 13 +- migrations/sqlite/V052__snooze.sql | 10 + migrations/sqlite/schema.sql | 13 +- packaging/rustbox.toml | 53 +++ src/auth/login_guard.rs | 512 +++++++++++++++++++++++++++ src/auth/mod.rs | 1 + src/config.rs | 347 ++++++++++++++++++ src/db/mod.rs | 1 + src/db/pool.rs | 43 ++- src/db/snooze.rs | 191 ++++++++++ src/main.rs | 2 + src/message_budget.rs | 185 ++++++++++ src/queries/mysql/queries.rs | 12 + src/queries/pgsql/queries.rs | 12 + src/queries/sqlite/queries.rs | 12 + src/routes/attachments.rs | 35 ++ src/routes/auth.rs | 357 ++++++++++++++++++- src/routes/drafts.rs | 35 +- src/routes/messages/mod.rs | 79 +++++ src/routes/messages/types.rs | 10 + src/routes/mod.rs | 58 ++- src/routes/send.rs | 273 ++++++++++---- src/routes/system_diagnostics.rs | 6 +- src/routes/tests.rs | 257 ++++++++++++++ src/undo_send.rs | 194 ++++++++++ src/web/css/50-settings.css | 38 ++ src/web/js/40-messages-reader.js | 70 +++- src/web/js/50-compose.js | 34 +- src/web/js/60-mail-actions-search.js | 127 +++++++ src/web/js/95-bindings-boot.js | 2 + 44 files changed, 3057 insertions(+), 128 deletions(-) create mode 100644 migrations/mysql/V052__snooze.sql create mode 100644 migrations/pgsql/V052__snooze.sql create mode 100644 migrations/sqlite/V052__snooze.sql create mode 100644 src/auth/login_guard.rs create mode 100644 src/db/snooze.rs create mode 100644 src/message_budget.rs create mode 100644 src/undo_send.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b59a6d4..fc74b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # 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 - Fixed interrupted initial-backfill recovery so legacy missing-date repair does not run against an invalidated or incomplete folder cache. diff --git a/Cargo.lock b/Cargo.lock index 8fa8bbd..521b557 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.20" +version = "0.2.22" dependencies = [ "aes-gcm", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 0a4c540..80f0fdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.20" +version = "0.2.22" edition = "2024" rust-version = "1.99.1" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index 96d93e7..3f209bf 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # 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` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index 9ecd82a..d555a02 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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. +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: ```toml @@ -77,6 +81,15 @@ tls_enabled = true max_attachment_size_mb = 10 # Hard limit for the complete rendered RFC822/MIME message submitted to the MTA. 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. 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. +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_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"`. diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 6fa7879..09cb17f 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -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` - 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 @@ -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 - 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 -- 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/compose-single-sqlite.yml @@ -74,14 +85,14 @@ Docker examples: Operations/docs: - 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 - 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 Build/release metadata: - `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 - 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 diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 2421dbc..298d84f 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -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` - 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 @@ -35,16 +45,16 @@ RustBox 0.2.20 - final source validation - PDF attachment preview uses the authenticated inline URL for Safari/iPhone compatibility - 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 -- V047-V050 are consolidated into compatibility baseline V050; V051 migrates existing tenants with adaptive HTML-mail colors and mailbox loading mode; V052 is next -- version metadata identifies 0.2.20 +- 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.22 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` - all JSON files parse successfully - Python helper scripts compile successfully - 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 diff --git a/config.dev.toml b/config.dev.toml index 3d40a1d..e627ccf 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -2,6 +2,7 @@ host = "127.0.0.1" port = 3001 environment = "development" +cookie_secure = "auto" # Global light/dark theme: system | dark | light default_theme = "system" # 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). max_attachment_size_mb = 10 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. sieve_enabled = true diff --git a/config.example.toml b/config.example.toml index 500b0d0..d74268e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -2,6 +2,12 @@ host = "127.0.0.1" port = 3001 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 default_theme = "system" # 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 # Maximum size of the complete RFC822/MIME message submitted to the MTA (MiB). 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". # base_path = "/mail" @@ -97,6 +119,36 @@ pgp_max_key_bytes = 1048576 # Private-key export is deliberately disabled unless the administrator opts in. 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] backend = "filesystem" # filesystem | s3 # path = "/var/lib/rustbox/blobs" # defaults to data_dir for filesystem diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 7242a88..3db2f35 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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. -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 diff --git a/migrations/README.md b/migrations/README.md index 8543d0c..87a18b6 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -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. -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 diff --git a/migrations/mysql/V052__snooze.sql b/migrations/mysql/V052__snooze.sql new file mode 100644 index 0000000..ba5c8b8 --- /dev/null +++ b/migrations/mysql/V052__snooze.sql @@ -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; diff --git a/migrations/mysql/schema.sql b/migrations/mysql/schema.sql index 079252c..d260d5c 100644 --- a/migrations/mysql/schema.sql +++ b/migrations/mysql/schema.sql @@ -1,7 +1,7 @@ -- RustBox 0.2.0 MySQL schema. -- One physical database contains global sessions plus isolated per-account tables. -- 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 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)) ) 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 ( role VARCHAR(255) PRIMARY KEY, folder_name TEXT NOT NULL, diff --git a/migrations/pgsql/V052__snooze.sql b/migrations/pgsql/V052__snooze.sql new file mode 100644 index 0000000..0ee0b31 --- /dev/null +++ b/migrations/pgsql/V052__snooze.sql @@ -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); diff --git a/migrations/pgsql/schema.sql b/migrations/pgsql/schema.sql index 60f1667..33ff170 100644 --- a/migrations/pgsql/schema.sql +++ b/migrations/pgsql/schema.sql @@ -1,7 +1,7 @@ -- RustBox 0.2.0 PostgreSQL schema. -- One physical database contains global sessions plus isolated per-account tables. -- 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 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) ); +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 ( role TEXT PRIMARY KEY, 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_pgp_keys_email ON {{p}}pgp_keys (lower(email)); 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 <> ''; -- RUSTBOX:TENANT-END diff --git a/migrations/sqlite/V052__snooze.sql b/migrations/sqlite/V052__snooze.sql new file mode 100644 index 0000000..7241b23 --- /dev/null +++ b/migrations/sqlite/V052__snooze.sql @@ -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); diff --git a/migrations/sqlite/schema.sql b/migrations/sqlite/schema.sql index 5fea266..d4e1d43 100644 --- a/migrations/sqlite/schema.sql +++ b/migrations/sqlite/schema.sql @@ -1,7 +1,7 @@ -- RustBox 0.2.0 SQLite schema. -- One physical database contains global sessions plus isolated per-account tables. -- 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 CREATE TABLE IF NOT EXISTS rustbox_accounts ( @@ -320,6 +320,15 @@ CREATE TABLE {{p}}saved_searches ( 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 ( role TEXT PRIMARY KEY CHECK(role IN ('sent','drafts','trash','junk','archive')), 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 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 <> ''; -- RUSTBOX:TENANT-END diff --git a/packaging/rustbox.toml b/packaging/rustbox.toml index 97a0c3d..c2b69e4 100644 --- a/packaging/rustbox.toml +++ b/packaging/rustbox.toml @@ -10,6 +10,12 @@ host = "0.0.0.0" port = 3001 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" log_dir = "/var/log/rustbox" @@ -127,6 +133,18 @@ max_attachment_size_mb = 50 # Maximum complete RFC822/MIME message size in MiB. 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 + +# ----------------------------------------------------------------------------- +# 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] # filesystem | s3 diff --git a/src/auth/login_guard.rs b/src/auth/login_guard.rs new file mode 100644 index 0000000..5938c7a --- /dev/null +++ b/src/auth/login_guard.rs @@ -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, + 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 { + self.locked_until + .and_then(|until| until.checked_duration_since(now)) + .filter(|remaining| !remaining.is_zero()) + } +} + +struct FailureTracker { + entries: Mutex>, + /// 0 disables this tracker. + max_failures: u32, +} + +impl FailureTracker { + fn new(max_failures: u32) -> Self { + Self { + entries: Mutex::new(HashMap::new()), + max_failures, + } + } + + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn remaining_lock(&self, key: &K, now: Instant) -> Option { + 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 { + 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, 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, + accounts: FailureTracker, + ips: FailureTracker, + 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 { + 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 { + 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); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 83a6ac9..c159589 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,5 +1,6 @@ pub mod csrf; pub mod imap_auth; +pub mod login_guard; pub mod middleware; pub mod session; pub mod user_data; diff --git a/src/config.rs b/src/config.rs index 1b50047..7c1399c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, + /// 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, 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 { + let trimmed = entry.trim(); + if let Ok(network) = trimmed.parse::() { + return Ok(network); + } + let ip = trimmed.parse::().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)] #[allow(dead_code)] pub struct AppConfig { @@ -213,6 +332,26 @@ pub struct AppConfig { #[serde(default = "default_max_message_size_mb")] 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")] pub data_dir: String, @@ -264,6 +403,9 @@ pub struct AppConfig { #[serde(default)] pub diagnostics_endpoint: DiagnosticsEndpointConfig, + #[serde(default)] + pub login_rate_limit: LoginRateLimitConfig, + #[serde(default = "default_session_timeout_hours")] pub session_timeout_hours: u64, @@ -276,6 +418,11 @@ pub struct AppConfig { #[serde(default = "default_environment")] 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")] pub default_theme: String, @@ -401,6 +548,18 @@ fn default_max_message_size_mb() -> usize { 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 { "/data".to_string() } @@ -473,6 +632,42 @@ fn default_search_worker_max_attempts() -> u32 { 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 { "info".to_string() } @@ -508,6 +703,10 @@ fn default_environment() -> String { "development".to_string() } +fn default_cookie_secure() -> String { + "auto".to_string() +} + fn default_theme() -> String { "system".to_string() } @@ -562,6 +761,9 @@ impl Default for AppConfig { tls_verify: None, max_attachment_size_mb: default_max_attachment_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(), log_dir: None, log_level: default_log_level(), @@ -579,10 +781,12 @@ impl Default for AppConfig { search: SearchConfig::default(), sync: SyncConfig::default(), diagnostics_endpoint: DiagnosticsEndpointConfig::default(), + login_rate_limit: LoginRateLimitConfig::default(), session_timeout_hours: default_session_timeout_hours(), remember_me_enabled: default_remember_me_enabled(), remember_me_days: default_remember_me_days(), environment: default_environment(), + cookie_secure: default_cookie_secure(), default_theme: default_theme(), default_interface_skin: default_interface_skin(), base_path: None, @@ -834,6 +1038,15 @@ impl AppConfig { 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()); } + 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") { return Err("DATABASE_BACKEND must be one of: sqlite, mysql, pgsql".to_string()); } @@ -1174,6 +1387,13 @@ impl AppConfig { } } 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 { 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_eq!(config.max_attachment_size_mb, 10); 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!(config.log_dir.is_none()); assert_eq!(config.database_backend, "sqlite"); @@ -1553,4 +1776,128 @@ mod tests { let error = config.validate().unwrap_err(); 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()); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 34ac2fc..c029d19 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -16,6 +16,7 @@ pub mod pool; pub mod response_templates; pub mod search_outbox; pub mod settings; +pub mod snooze; pub mod special_folders; pub mod tags; pub mod trusted_senders; diff --git a/src/db/pool.rs b/src/db/pool.rs index ce9f8fb..aa6fe69 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -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 PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql"); -const SQLITE_MIGRATIONS: &[(u32, &str)] = &[( - 51, - include_str!( - "../../migrations/sqlite/V051__adaptive_email_colors_and_message_loading_mode.sql" +const SQLITE_MIGRATIONS: &[(u32, &str)] = &[ + ( + 51, + include_str!( + "../../migrations/sqlite/V051__adaptive_email_colors_and_message_loading_mode.sql" + ), ), -)]; -const MYSQL_MIGRATIONS: &[(u32, &str)] = &[( - 51, - include_str!("../../migrations/mysql/V051__adaptive_email_colors_and_message_loading_mode.sql"), -)]; -const PGSQL_MIGRATIONS: &[(u32, &str)] = &[( - 51, - include_str!("../../migrations/pgsql/V051__adaptive_email_colors_and_message_loading_mode.sql"), -)]; + (52, include_str!("../../migrations/sqlite/V052__snooze.sql")), +]; +const MYSQL_MIGRATIONS: &[(u32, &str)] = &[ + ( + 51, + include_str!( + "../../migrations/mysql/V051__adaptive_email_colors_and_message_loading_mode.sql" + ), + ), + (52, include_str!("../../migrations/mysql/V052__snooze.sql")), +]; +const PGSQL_MIGRATIONS: &[(u32, &str)] = &[ + ( + 51, + 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] = &[ "blob_delete_outbox", @@ -61,6 +74,7 @@ const TENANT_TABLES: &[&str] = &[ "response_templates", "saved_searches", "search_outbox", + "snoozed_messages", "special_folders", "tags", "trusted_senders", @@ -87,6 +101,7 @@ const IMPORT_TABLES: &[&str] = &[ "messages", "tags", "message_tags", + "snoozed_messages", "notification_preferences", "pgp_keys", "pgp_preferences", @@ -2648,7 +2663,7 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, 51); + assert_eq!(version, 52); let adaptive: i64 = conn .query_row( diff --git a/src/db/snooze.rs b/src/db/snooze.rs new file mode 100644 index 0000000..e2e3f0d --- /dev/null +++ b/src/db/snooze.rs @@ -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 { + 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 { + 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, 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>(4)?.unwrap_or_default(), + from_name: row.get::<_, Option>(5)?.unwrap_or_default(), + from_address: row.get::<_, Option>(6)?.unwrap_or_default(), + snippet: row.get::<_, Option>(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, 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]); + } +} diff --git a/src/main.rs b/src/main.rs index 85cdccb..47281b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod db; mod email_theme; mod error; mod imap; +mod message_budget; mod pgp; mod queries; mod realtime; @@ -15,6 +16,7 @@ mod sieve; mod smtp; mod storage; mod ui; +mod undo_send; use std::fs::OpenOptions; use std::sync::{Arc, Mutex}; diff --git a/src/message_budget.rs b/src/message_budget.rs new file mode 100644 index 0000000..469cc45 --- /dev/null +++ b/src/message_budget.rs @@ -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, + 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 { + 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()); + } +} diff --git a/src/queries/mysql/queries.rs b/src/queries/mysql/queries.rs index 5beeafe..37f490b 100644 --- a/src/queries/mysql/queries.rs +++ b/src/queries/mysql/queries.rs @@ -406,6 +406,18 @@ pub mod db_saved_searches { 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 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 = ?)"; diff --git a/src/queries/pgsql/queries.rs b/src/queries/pgsql/queries.rs index 261d031..2aed288 100644 --- a/src/queries/pgsql/queries.rs +++ b/src/queries/pgsql/queries.rs @@ -406,6 +406,18 @@ pub mod db_saved_searches { 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 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)"; diff --git a/src/queries/sqlite/queries.rs b/src/queries/sqlite/queries.rs index 2735ea3..6b2d292 100644 --- a/src/queries/sqlite/queries.rs +++ b/src/queries/sqlite/queries.rs @@ -403,6 +403,18 @@ pub mod db_saved_searches { 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 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)"; diff --git a/src/routes/attachments.rs b/src/routes/attachments.rs index 1d4a2da..6a45be0 100644 --- a/src/routes/attachments.rs +++ b/src/routes/attachments.rs @@ -13,6 +13,7 @@ use crate::config::AppConfig; use crate::db; use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::message_budget::{MessageBuildLimiter, draft_attachments_total, ensure_attachments_fit}; use crate::storage::{self, BlobStorage, attachment_key}; #[derive(Debug, Serialize)] @@ -56,6 +57,13 @@ pub async fn upload_attachment( let mut uploaded = Vec::new(); 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 .next_field() @@ -76,6 +84,7 @@ pub async fn upload_attachment( .map_err(|e| AppError::ServiceUnavailable(format!("Storage unavailable: {e}")))?; let mut size: usize = 0; let mut checksum = Sha256::new(); + message_attachment_parts += 1; loop { let chunk = match field.chunk().await { @@ -107,6 +116,19 @@ pub async fn upload_attachment( 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); if let Err(e) = writer.write(chunk).await { if writer.abort().await.is_err() @@ -145,6 +167,7 @@ pub async fn upload_attachment( ))); } let checksum = format!("{:x}", checksum.finalize()); + message_attachment_bytes = message_attachment_bytes.saturating_add(size as u64); let size = size as i64; let attachment = db::drafts::NewDraftAttachment { id: &att_id, @@ -187,6 +210,7 @@ pub async fn attach_message_source( Extension(config): Extension>, Extension(storage): Extension>, Extension(imap_client): Extension>, + Extension(build_limiter): Extension>, AxumPath(draft_id): AxumPath, Json(req): Json, ) -> Result { @@ -205,6 +229,9 @@ pub async fn attach_message_source( email: session.email.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 .fetch_raw_message(&creds, &req.folder, req.uid) .await @@ -232,6 +259,14 @@ pub async fn attach_message_source( .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 content_type = "message/rfc822".to_string(); let att_id = uuid::Uuid::new_v4().to_string(); diff --git a/src/routes/auth.rs b/src/routes/auth.rs index be5cbce..f14bae7 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1,12 +1,14 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; -use axum::extract::Path; +use axum::extract::{ConnectInfo, Path}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use serde::{Deserialize, Serialize}; use crate::auth::imap_auth::{self, AuthResult}; +use crate::auth::login_guard::{LoginBlocked, LoginGuard}; use crate::auth::session::{NewAccountSession, SessionState, SessionStore}; use crate::auth::user_data; use crate::config::AppConfig; @@ -150,9 +152,133 @@ fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, Stri 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::>() + .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 { + 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( Extension(store): Extension>, Extension(config): Extension>, + extensions: axum::http::Extensions, headers: axum::http::HeaderMap, Json(body): Json, ) -> Response { @@ -192,6 +318,20 @@ pub async fn login( .into_response(); }; + let login_guard = extensions.get::>().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 .smtp_host .clone() @@ -214,6 +354,23 @@ pub async fn login( ) .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 { AuthResult::Success => { let provided_browser_id = body @@ -366,7 +523,7 @@ pub async fn login( store.set_user_agent_shared(&account_id, user_agent).await; 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 path = cookie_path(&config); let browser_cookie_header = @@ -439,9 +596,10 @@ pub async fn get_session(Extension(session): Extension) -> Respons pub async fn list_accounts( Extension(store): Extension>, Extension(config): Extension>, + extensions: axum::http::Extensions, headers: axum::http::HeaderMap, ) -> Response { - let secure = config.environment != "development"; + let secure = cookie_secure(&config, &extensions, &headers); let same_site = same_site_policy(&config); let browser_id = extract_browser_id(&headers); @@ -570,6 +728,7 @@ pub async fn remove_account( Extension(store): Extension>, Extension(config): Extension>, Path(account_id): Path, + extensions: axum::http::Extensions, headers: axum::http::HeaderMap, ) -> Response { let browser_id = extract_browser_id(&headers); @@ -611,7 +770,7 @@ pub async fn remove_account( 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 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( Extension(store): Extension>, Extension(config): Extension>, + extensions: axum::http::Extensions, headers: axum::http::HeaderMap, ) -> Response { let browser_id = extract_browser_id(&headers); let mut cookies_to_clear: Vec = Vec::new(); - let secure = config.environment != "development"; + let secure = cookie_secure(&config, &extensions, &headers); let same_site = same_site_policy(&config); if let Some(ref browser_id) = browser_id { @@ -947,6 +1107,7 @@ mod tests { let first = login( Extension(store.clone()), Extension(config.clone()), + axum::http::Extensions::new(), axum::http::HeaderMap::new(), Json(request("first@example.com")), ) @@ -968,6 +1129,7 @@ mod tests { let second = login( Extension(store.clone()), Extension(config.clone()), + axum::http::Extensions::new(), headers.clone(), Json(request("second@example.com")), ) @@ -995,6 +1157,7 @@ mod tests { let duplicate = login( Extension(store.clone()), Extension(config), + axum::http::Extensions::new(), headers, Json(request("second@example.com")), ) @@ -1004,6 +1167,190 @@ mod tests { 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::( + "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::(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] fn issued_and_clearing_cookies_use_the_normalized_base_path() { for (base_path, expected_path) in [ diff --git a/src/routes/drafts.rs b/src/routes/drafts.rs index 4eb5e8d..ee4c51a 100644 --- a/src/routes/drafts.rs +++ b/src/routes/drafts.rs @@ -11,6 +11,7 @@ use crate::config::AppConfig; use crate::db; use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::message_budget::{MessageBuildLimiter, draft_attachments_total, ensure_attachments_fit}; use crate::smtp::client::AttachmentData; use crate::storage::{self, BlobStorage}; @@ -240,6 +241,7 @@ pub async fn upsert_draft_handler( Extension(config): Extension>, Extension(imap_client): Extension>, Extension(storage): Extension>, + Extension(build_limiter): Extension>, Json(req): Json, ) -> Result { 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)?; 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 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 .map_err(|e| { 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?; 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 { Some( db::identities::get_identity(&conn, identity_id) @@ -699,12 +726,10 @@ async fn delete_imap_draft_source( Ok(true) } -async fn load_snapshot_attachments( - conn: &crate::db::pool::Connection, - draft_id: &str, +async fn read_snapshot_attachments( storage: &dyn BlobStorage, + records: Vec, ) -> Result, String> { - let records = db::drafts::get_draft_attachments(conn, draft_id)?; let mut attachments = Vec::with_capacity(records.len()); for record in records { let data = storage::read_all(storage, &record.storage_key) diff --git a/src/routes/messages/mod.rs b/src/routes/messages/mod.rs index 1674de8..63f3d4a 100644 --- a/src/routes/messages/mod.rs +++ b/src/routes/messages/mod.rs @@ -1830,6 +1830,85 @@ pub async fn delete_message_handler( 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, + Extension(config): Extension>, + Path((folder, uid)): Path<(String, u32)>, + Json(body): Json, +) -> Result { + 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, + Extension(config): Extension>, + Path((folder, uid)): Path<(String, u32)>, +) -> Result { + 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, + Extension(config): Extension>, +) -> Result { + 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, + Extension(config): Extension>, + Path(folder): Path, +) -> Result { + 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)] mod sync_tests { use super::*; diff --git a/src/routes/messages/types.rs b/src/routes/messages/types.rs index 36235f7..f1744ec 100644 --- a/src/routes/messages/types.rs +++ b/src/routes/messages/types.rs @@ -42,6 +42,16 @@ pub(crate) struct CopyMessageRequest { 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, + #[serde(default)] + pub(crate) snooze_for_secs: Option, +} + #[derive(Serialize)] pub(crate) struct ListMessagesResponse { pub(crate) messages: Vec, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 2255238..fe65f9f 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -45,10 +45,12 @@ use tower_governor::key_extractor::KeyExtractor; use tower_http::trace::TraceLayer; use crate::auth::csrf::csrf_protection; +use crate::auth::login_guard::LoginGuard; use crate::auth::middleware::auth_guard; use crate::auth::session::SessionStore; use crate::config::{AppConfig, TrustedProxy}; use crate::imap::client::ImapClient; +use crate::message_budget::MessageBuildLimiter; use crate::realtime::events::EventBus; use crate::realtime::idle::IdleManager; use crate::smtp::client::SmtpClient; @@ -230,20 +232,38 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic } = services; let system_diagnostics_telemetry = Arc::new(system_diagnostics::SystemDiagnosticsTelemetry::new()); - let governor_conf = GovernorConfigBuilder::default() - .key_extractor(ProxyAwareIpExtractor { - trusted_proxies: config.parsed_trusted_proxies(), - }) - .period(Duration::from_secs(12)) - .burst_size(5) - .finish() - .expect("valid governor config"); + 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() + .key_extractor(ProxyAwareIpExtractor { + trusted_proxies: config.parsed_trusted_proxies(), + }) + .period(Duration::from_secs( + login_rate_limit.ip_replenish_secs.max(1), + )) + .burst_size(login_rate_limit.ip_burst.max(1)) + .finish() + .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() - .route("/login", post(auth::login)) .route("/policy", get(auth::auth_policy)) - .layer(middleware::from_fn(csrf_protection)) - .layer(GovernorLayer::new(governor_conf)); + .merge(login_route) + .layer(middleware::from_fn(csrf_protection)); let browser_routes = Router::new() .route("/accounts", get(auth::list_accounts)) @@ -293,6 +313,10 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic post(messages::import_eml).layer(DefaultBodyLimit::max(52 * 1024 * 1024)), ) .route("/folders/{folder}/messages", get(messages::list_messages)) + .route( + "/folders/{name}/snoozed-uids", + get(messages::list_sleeping_uids), + ) .route( "/messages/{folder}/{uid}", get(messages::get_message).delete(messages::delete_message_handler), @@ -301,6 +325,11 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic "/messages/{folder}/{uid}/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( "/messages/{folder}/{uid}/attachments/{attachment_id}", get(messages::download_attachment), @@ -316,6 +345,10 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic .route("/messages/move", post(messages::move_message_handler)) .route("/messages/copy", post(messages::copy_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( "/messages/{folder}/{uid}/unsubscribe", post(unsubscribe::unsubscribe_message), @@ -658,6 +691,9 @@ pub fn create_router_with_storage(config: Arc, services: RouterServic .layer(Extension(storage)) .layer(Extension(imap_client)) .layer(Extension(store)) + .layer(Extension(login_guard)) + .layer(Extension(build_limiter)) + .layer(Extension(undo_send_registry)) .layer(Extension(config.clone())) .layer(TraceLayer::new_for_http()); diff --git a/src/routes/send.rs b/src/routes/send.rs index 6c11283..335316b 100644 --- a/src/routes/send.rs +++ b/src/routes/send.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use std::time::Duration; +use axum::extract::Path; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use base64::Engine as _; @@ -11,10 +13,12 @@ use crate::config::AppConfig; use crate::db; use crate::error::AppError; 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::smtp::client::{AttachmentData, SendableMessage, SmtpClient, SmtpCredentials}; use crate::smtp::types::{PgpMode, PgpSendConfig}; use crate::storage::{self, BlobStorage}; +use crate::undo_send::{PendingOutcome, UndoSendRegistry}; #[derive(Deserialize)] pub struct SendRequest { @@ -251,14 +255,25 @@ fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> { pub struct SendResponse { pub status: 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub undo_window_secs: Option, } +#[allow(clippy::too_many_arguments)] pub async fn send_message_handler( Extension(session): Extension, Extension(config): Extension>, Extension(smtp_client): Extension>, Extension(imap_client): Extension>, Extension(storage): Extension>, + Extension(build_limiter): Extension>, + Extension(undo_registry): Extension>, Json(req): Json, ) -> Result { let imap_source = req.imap_source.clone(); @@ -443,17 +458,22 @@ pub async fn send_message_handler( validate_mailbox("read receipt", &receipt_address)?; } - let mut attachments = if let Some(ref draft_id) = req.draft_id { - load_draft_attachments( - &config.data_dir, - &session.user_hash, - draft_id, - storage.as_ref(), - ) - .await? + // Reject an oversized attachment set from the stored sizes before any blob + // is read, then bound how many requests hold attachment data in RAM at once. + // The permit lives until the message has been sent and appended to Sent. + let stored_attachments = if let Some(ref draft_id) = req.draft_id { + draft_attachment_records(&config, &session.user_hash, draft_id)? } 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() { let mut remote_attachments = crate::routes::drafts::load_imap_source_attachments( 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 { from: from_address, to: req.to, @@ -532,8 +564,107 @@ pub async fn send_message_handler( 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 { let sent = smtp_client - .send_message(&smtp_creds, &message) + .send_message(smtp_creds, message) .await .map_err(|e| match e { 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 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 .run(|conn| { Ok(db::special_folders::get_role(conn, "sent")? @@ -568,7 +690,7 @@ pub async fn send_message_handler( .unwrap_or_else(|_| "Sent".to_string()); match imap_client - .append_message(&imap_creds, &sent_folder, &sent.rfc822, &["\\Seen"]) + .append_message(imap_creds, &sent_folder, &sent.rfc822, &["\\Seen"]) .await { Ok(()) => { @@ -587,62 +709,93 @@ pub async fn send_message_handler( } let mut source_removed_with_local_draft = false; - if let Some(ref draft_id) = req.draft_id { - if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash) + if let Some(draft_id) = draft_id { + 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) { - 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_uid == Some(source.uid) && draft.remote_uid_validity == Some(source.uid_validity) }); - crate::routes::drafts::remove_remote_draft_snapshot( - imap_client.as_ref(), - &imap_creds, - &draft, - ) - .await; + crate::routes::drafts::remove_remote_draft_snapshot(imap_client, imap_creds, &draft) + .await; } - cleanup_draft( - &config.data_dir, - &session.user_hash, - draft_id, - storage.as_ref(), - ) - .await; + cleanup_draft(&config.data_dir, user_hash, draft_id, storage).await; } - if !source_removed_with_local_draft && let Some(source) = imap_source.as_ref() { - crate::routes::drafts::remove_imap_source_snapshot( - imap_client.as_ref(), - &imap_creds, - source, - "", - 0, - ) - .await; + if !source_removed_with_local_draft && let Some(source) = imap_source { + crate::routes::drafts::remove_imap_source_snapshot(imap_client, imap_creds, source, "", 0) + .await; } - Ok(Json(SendResponse { - status: "sent".to_string(), - message_id, - }) - .into_response()) + Ok(message_id) } -async fn load_draft_attachments( - data_dir: &str, +/// `DELETE /messages/send/{pending_send_id}`: cancels a message still inside +/// 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>, + Path(pending_id): Path, +) -> Result { + 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>, + Path(pending_id): Path, +) -> Result { + 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, draft_id: &str, - storage: &dyn BlobStorage, -) -> Result, AppError> { - let conn = db::pool::open_user_db(data_dir, user_hash) +) -> Result, AppError> { + let conn = db::pool::open_user_db(&config.data_dir, user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; - - let db_attachments = + let records = 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()); - for att in db_attachments { +async fn read_draft_attachments( + storage: &dyn BlobStorage, + records: Vec, +) -> Result, AppError> { + let mut attachments = Vec::with_capacity(records.len()); + for att in records { let data = storage::read_all(storage, &att.storage_key) .await .map_err(|e| { diff --git a/src/routes/system_diagnostics.rs b/src/routes/system_diagnostics.rs index d3e518a..2aab567 100644 --- a/src/routes/system_diagnostics.rs +++ b/src/routes/system_diagnostics.rs @@ -189,7 +189,11 @@ fn trusted_contains(trusted: &[TrustedProxy], ip: &IpAddr) -> bool { 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(); if !trusted_contains(&trusted, &peer_ip) { return peer_ip; diff --git a/src/routes/tests.rs b/src/routes/tests.rs index 4d7f908..8cd5779 100644 --- a/src/routes/tests.rs +++ b/src/routes/tests.rs @@ -86,6 +86,12 @@ fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc { sieve_timeout_secs: 10, trusted_proxies: None, 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() }) } @@ -2720,3 +2726,254 @@ async fn export_contact_strips_control_chars_from_filename() { assert!(!cd.contains('\n')); } } + +fn multipart_upload_request( + uri: &str, + filename: &str, + payload: &[u8], + auth: &[(&'static str, String)], +) -> Request { + 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 = 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); +} diff --git a/src/undo_send.rs b/src/undo_send.rs new file mode 100644 index 0000000..08c5639 --- /dev/null +++ b/src/undo_send.rs @@ -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>, +} + +impl UndoSendRegistry { + pub fn new() -> Self { + Self::default() + } + + fn sweep(entries: &mut HashMap) { + 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 { + 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")); + } +} diff --git a/src/web/css/50-settings.css b/src/web/css/50-settings.css index 8ae543f..771a827 100644 --- a/src/web/css/50-settings.css +++ b/src/web/css/50-settings.css @@ -364,6 +364,44 @@ 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 { margin-right: auto; } diff --git a/src/web/js/40-messages-reader.js b/src/web/js/40-messages-reader.js index d85de61..a92350d 100644 --- a/src/web/js/40-messages-reader.js +++ b/src/web/js/40-messages-reader.js @@ -98,6 +98,7 @@ async function loadMessages(reset = false, options = {}) { } $("#connection-label").textContent = "Connected"; renderMessages(); + void applySnoozeHiding(); const desiredScroll = savedState?.scrollTop ?? previousScrollTop; if (mode === "pagination") { 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() { $("#folder-title").textContent = state.folder; 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("click", (event) => { - if (event.shiftKey) { + if (state.selectionMode) { + if (event.shiftKey) { + event.preventDefault(); + selectMessageRange(message); + return; + } + return setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); + } + if (event.ctrlKey || event.metaKey) { event.preventDefault(); - selectMessageRange(message); + openMessageInNewTab(folder, message.uid); + return; + } + if (event.shiftKey) { + event.preventDefault(); + openMessageInPopup(folder, message.uid); return; } - if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); setMessageSelectionAnchor(message); 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); - if (detail?.folder && Number.isFinite(Number(detail.uid))) { - url.searchParams.set("mail_folder", detail.folder); - url.searchParams.set("mail_uid", String(detail.uid)); - url.searchParams.set("mail_fullscreen", "1"); - } + url.searchParams.set("mail_folder", folder); + url.searchParams.set("mail_uid", String(uid)); + url.searchParams.set("mail_fullscreen", "1"); 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() { const reader = $("#reader-content"); if (!reader) return; diff --git a/src/web/js/50-compose.js b/src/web/js/50-compose.js index b758788..1e60359 100644 --- a/src/web/js/50-compose.js +++ b/src/web/js/50-compose.js @@ -1788,6 +1788,32 @@ function buildReferences(detail) { 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) { event.preventDefault(); if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning"); @@ -1821,7 +1847,7 @@ async function sendMessage(event) { setBusy(send, true); const editor = $("#compose-editor"); try { - await request("/messages/send", { + const sendResult = await request("/messages/send", { method: "POST", body: JSON.stringify({ to: parseRecipients($("#compose-to").value), @@ -1865,7 +1891,11 @@ async function sendMessage(event) { state.pgpRecipientStatus = []; $("#compose-pgp-passphrase").value = ""; updateComposeMessageSize(); - toast("Message sent", "success"); + if (sendResult?.status === "pending" && sendResult.pending_send_id) { + notifyUndoSendStaged(sendResult.pending_send_id, Number(sendResult.undo_window_secs) || 0); + } else { + toast("Message sent", "success"); + } await loadFolders(); } catch (error) { state.composeDirty = true; diff --git a/src/web/js/60-mail-actions-search.js b/src/web/js/60-mail-actions-search.js index 4987db6..6b037ae 100644 --- a/src/web/js/60-mail-actions-search.js +++ b/src/web/js/60-mail-actions-search.js @@ -181,6 +181,131 @@ async function copyCurrentTo(targetFolder) { } 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"; function configuredBrowserSpamFolder() { @@ -602,6 +727,8 @@ function openMessageContextMenu(x, y, message) { { label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } }, "separator", { 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: "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); } }, diff --git a/src/web/js/95-bindings-boot.js b/src/web/js/95-bindings-boot.js index 8fd440f..c43b208 100644 --- a/src/web/js/95-bindings-boot.js +++ b/src/web/js/95-bindings-boot.js @@ -678,6 +678,7 @@ function bindEvents() { const globalKey = event.key.toLowerCase(); if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; } if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; } + if (globalKey === "b" && event.shiftKey) { event.preventDefault(); showSnoozedMessages(); return; } } if (appShortcut && state.currentDetail) { const key = event.key.toLowerCase(); @@ -691,6 +692,7 @@ function bindEvents() { else if (key === "a") { event.preventDefault(); openCompose("reply-all"); } else if (key === "f") { event.preventDefault(); openCompose("forward"); } else if (key === "e") { event.preventDefault(); archiveCurrent(); } + else if (key === "b") { event.preventDefault(); promptSnoozeCurrent(); } else if (key === "m") { event.preventDefault(); toggleUnread(); } else if (key === "s") { event.preventDefault(); toggleFlagged(); } else if (event.key === "!") { event.preventDefault(); junkCurrent(); }