1 parent
6e74b33f03
commit
3b9110c8c8
44 files changed
+3040
-111
No files matched your search
@@ -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.
|
||||
|
||||
Generated
+1
-1
@@ -3506,7 +3506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustbox"
|
||||
version = "0.2.20"
|
||||
version = "0.2.22"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-imap",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
+16
-5
@@ -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
|
||||
+16
-6
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Login brute-force protection.
|
||||
//!
|
||||
//! The per-IP token bucket (`tower_governor`, configured in `routes::mod`) caps the
|
||||
//! raw request rate on `POST /api/auth/login`. This module adds failure-based
|
||||
//! lockouts on top of it:
|
||||
//!
|
||||
//! * per account (case-insensitive e-mail, regardless of client IP) — stops slow,
|
||||
//! distributed password guessing against one mailbox;
|
||||
//! * per client IP (any account) — stops password spraying from one source.
|
||||
//!
|
||||
//! Consecutive lockouts of the same key double the lockout time up to
|
||||
//! `max_lockout_secs`. A locked request is rejected before RustBox opens an IMAP
|
||||
//! connection, so brute force does not reach (or trigger lockouts on) the mail
|
||||
//! server. State is in memory and per node; see `[login_rate_limit]` in the
|
||||
//! configuration for all knobs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ipnet::IpNet;
|
||||
|
||||
use crate::config::LoginRateLimitConfig;
|
||||
|
||||
/// Expired entries are swept every N recorded failures.
|
||||
const PRUNE_EVERY: u64 = 256;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LockScope {
|
||||
Account,
|
||||
Ip,
|
||||
}
|
||||
|
||||
impl LockScope {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Account => "account",
|
||||
Self::Ip => "ip",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LoginBlocked {
|
||||
pub retry_after: Duration,
|
||||
pub scope: LockScope,
|
||||
}
|
||||
|
||||
impl LoginBlocked {
|
||||
/// Whole seconds for the `Retry-After` header (rounded up, at least 1).
|
||||
pub fn retry_after_secs(&self) -> u64 {
|
||||
let secs = self.retry_after.as_secs();
|
||||
let secs = if self.retry_after.subsec_nanos() > 0 {
|
||||
secs.saturating_add(1)
|
||||
} else {
|
||||
secs
|
||||
};
|
||||
secs.max(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Policy {
|
||||
failure_window: Duration,
|
||||
lockout: Duration,
|
||||
max_lockout: Duration,
|
||||
max_tracked_keys: usize,
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
/// Lockout duration for the n-th consecutive lockout (n >= 1).
|
||||
fn lockout_for(&self, lockouts: u32) -> Duration {
|
||||
let exponent = lockouts.saturating_sub(1).min(20);
|
||||
self.lockout
|
||||
.saturating_mul(1u32 << exponent)
|
||||
.min(self.max_lockout)
|
||||
}
|
||||
|
||||
/// Idle time after which an unlocked entry (and its back-off history) is dropped.
|
||||
fn idle_ttl(&self) -> Duration {
|
||||
self.failure_window.max(self.max_lockout)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FailureEntry {
|
||||
window_start: Instant,
|
||||
failures: u32,
|
||||
lockouts: u32,
|
||||
locked_until: Option<Instant>,
|
||||
last_activity: Instant,
|
||||
}
|
||||
|
||||
impl FailureEntry {
|
||||
fn new(now: Instant) -> Self {
|
||||
Self {
|
||||
window_start: now,
|
||||
failures: 0,
|
||||
lockouts: 0,
|
||||
locked_until: None,
|
||||
last_activity: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn remaining_lock(&self, now: Instant) -> Option<Duration> {
|
||||
self.locked_until
|
||||
.and_then(|until| until.checked_duration_since(now))
|
||||
.filter(|remaining| !remaining.is_zero())
|
||||
}
|
||||
}
|
||||
|
||||
struct FailureTracker<K> {
|
||||
entries: Mutex<HashMap<K, FailureEntry>>,
|
||||
/// 0 disables this tracker.
|
||||
max_failures: u32,
|
||||
}
|
||||
|
||||
impl<K: Eq + Hash> FailureTracker<K> {
|
||||
fn new(max_failures: u32) -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
max_failures,
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, HashMap<K, FailureEntry>> {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn remaining_lock(&self, key: &K, now: Instant) -> Option<Duration> {
|
||||
if self.max_failures == 0 {
|
||||
return None;
|
||||
}
|
||||
self.lock()
|
||||
.get(key)
|
||||
.and_then(|entry| entry.remaining_lock(now))
|
||||
}
|
||||
|
||||
/// Records one failure; returns the new lockout duration if this failure
|
||||
/// triggered a lockout.
|
||||
fn record_failure(&self, key: K, now: Instant, policy: &Policy) -> Option<Duration> {
|
||||
if self.max_failures == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut entries = self.lock();
|
||||
if !entries.contains_key(&key) && entries.len() >= policy.max_tracked_keys {
|
||||
Self::prune_locked(&mut entries, now, policy);
|
||||
if entries.len() >= policy.max_tracked_keys {
|
||||
tracing::warn!(
|
||||
tracked = entries.len(),
|
||||
"login rate limiter table is full; new key not tracked"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let entry = entries.entry(key).or_insert_with(|| FailureEntry::new(now));
|
||||
if entry.remaining_lock(now).is_some() {
|
||||
// Requests that were already in flight when the lock was set.
|
||||
entry.last_activity = now;
|
||||
return None;
|
||||
}
|
||||
if entry.lockouts > 0 && now.duration_since(entry.last_activity) >= policy.idle_ttl() {
|
||||
entry.lockouts = 0;
|
||||
}
|
||||
if now.duration_since(entry.window_start) >= policy.failure_window {
|
||||
entry.window_start = now;
|
||||
entry.failures = 0;
|
||||
}
|
||||
|
||||
entry.failures = entry.failures.saturating_add(1);
|
||||
entry.last_activity = now;
|
||||
if entry.failures < self.max_failures {
|
||||
return None;
|
||||
}
|
||||
|
||||
entry.lockouts = entry.lockouts.saturating_add(1);
|
||||
let duration = policy.lockout_for(entry.lockouts);
|
||||
entry.locked_until = Some(now + duration);
|
||||
entry.failures = 0;
|
||||
entry.window_start = now;
|
||||
Some(duration)
|
||||
}
|
||||
|
||||
fn clear(&self, key: &K) {
|
||||
self.lock().remove(key);
|
||||
}
|
||||
|
||||
fn prune(&self, now: Instant, policy: &Policy) {
|
||||
Self::prune_locked(&mut self.lock(), now, policy);
|
||||
}
|
||||
|
||||
fn prune_locked(entries: &mut HashMap<K, FailureEntry>, now: Instant, policy: &Policy) {
|
||||
let ttl = policy.idle_ttl();
|
||||
entries.retain(|_, entry| {
|
||||
entry.remaining_lock(now).is_some() || now.duration_since(entry.last_activity) < ttl
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn len(&self) -> usize {
|
||||
self.lock().len()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LoginGuard {
|
||||
enabled: bool,
|
||||
policy: Policy,
|
||||
exempt_networks: Vec<IpNet>,
|
||||
accounts: FailureTracker<String>,
|
||||
ips: FailureTracker<IpAddr>,
|
||||
recorded_failures: AtomicU64,
|
||||
}
|
||||
|
||||
impl LoginGuard {
|
||||
pub fn from_config(config: &LoginRateLimitConfig) -> Self {
|
||||
let exempt_networks = config.parsed_exempt_networks().unwrap_or_else(|error| {
|
||||
// AppConfig::validate rejects this at startup; never fail open silently.
|
||||
tracing::error!(%error, "ignoring invalid login_rate_limit.exempt_networks");
|
||||
Vec::new()
|
||||
});
|
||||
Self {
|
||||
enabled: config.enabled,
|
||||
policy: Policy {
|
||||
failure_window: Duration::from_secs(config.failure_window_secs.max(1)),
|
||||
lockout: Duration::from_secs(config.lockout_secs.max(1)),
|
||||
max_lockout: Duration::from_secs(
|
||||
config.max_lockout_secs.max(config.lockout_secs).max(1),
|
||||
),
|
||||
max_tracked_keys: config.max_tracked_keys.max(1),
|
||||
},
|
||||
exempt_networks,
|
||||
accounts: FailureTracker::new(config.account_max_failures),
|
||||
ips: FailureTracker::new(config.ip_max_failures),
|
||||
recorded_failures: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized account key: the same mailbox must not get a fresh budget by
|
||||
/// changing letter case or adding whitespace.
|
||||
pub fn account_key(email: &str) -> String {
|
||||
email.trim().to_lowercase()
|
||||
}
|
||||
|
||||
fn ip_is_tracked(&self, ip: &IpAddr) -> bool {
|
||||
!self
|
||||
.exempt_networks
|
||||
.iter()
|
||||
.any(|network| network.contains(ip))
|
||||
}
|
||||
|
||||
/// Returns `Err` when the account or client IP is currently locked out.
|
||||
pub fn check(&self, email: &str, ip: IpAddr) -> Result<(), LoginBlocked> {
|
||||
self.check_at(email, ip, Instant::now())
|
||||
}
|
||||
|
||||
/// Records a failed login (wrong credentials). Returns the lockout that this
|
||||
/// failure triggered, if any, so the caller can log it.
|
||||
pub fn record_failure(&self, email: &str, ip: IpAddr) -> Option<LoginBlocked> {
|
||||
self.record_failure_at(email, ip, Instant::now())
|
||||
}
|
||||
|
||||
/// Successful login resets the account's failure history. The IP history is
|
||||
/// kept so an attacker cannot reset a spraying budget with their own account.
|
||||
pub fn record_success(&self, email: &str) {
|
||||
if self.enabled {
|
||||
self.accounts.clear(&Self::account_key(email));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn tracked_keys(&self) -> (usize, usize) {
|
||||
(self.accounts.len(), self.ips.len())
|
||||
}
|
||||
|
||||
fn check_at(&self, email: &str, ip: IpAddr, now: Instant) -> Result<(), LoginBlocked> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let account = self
|
||||
.accounts
|
||||
.remaining_lock(&Self::account_key(email), now)
|
||||
.map(|retry_after| LoginBlocked {
|
||||
retry_after,
|
||||
scope: LockScope::Account,
|
||||
});
|
||||
let ip = if self.ip_is_tracked(&ip) {
|
||||
self.ips
|
||||
.remaining_lock(&ip, now)
|
||||
.map(|retry_after| LoginBlocked {
|
||||
retry_after,
|
||||
scope: LockScope::Ip,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (account, ip) {
|
||||
(Some(a), Some(i)) => Err(if a.retry_after >= i.retry_after { a } else { i }),
|
||||
(Some(blocked), None) | (None, Some(blocked)) => Err(blocked),
|
||||
(None, None) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_failure_at(&self, email: &str, ip: IpAddr, now: Instant) -> Option<LoginBlocked> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.recorded_failures
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.is_multiple_of(PRUNE_EVERY)
|
||||
{
|
||||
self.accounts.prune(now, &self.policy);
|
||||
self.ips.prune(now, &self.policy);
|
||||
}
|
||||
|
||||
let account = self
|
||||
.accounts
|
||||
.record_failure(Self::account_key(email), now, &self.policy)
|
||||
.map(|retry_after| LoginBlocked {
|
||||
retry_after,
|
||||
scope: LockScope::Account,
|
||||
});
|
||||
let ip = if self.ip_is_tracked(&ip) {
|
||||
self.ips
|
||||
.record_failure(ip, now, &self.policy)
|
||||
.map(|retry_after| LoginBlocked {
|
||||
retry_after,
|
||||
scope: LockScope::Ip,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
account.or(ip)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config() -> LoginRateLimitConfig {
|
||||
LoginRateLimitConfig {
|
||||
account_max_failures: 3,
|
||||
ip_max_failures: 5,
|
||||
failure_window_secs: 60,
|
||||
lockout_secs: 10,
|
||||
max_lockout_secs: 40,
|
||||
..LoginRateLimitConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ip(value: &str) -> IpAddr {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_is_locked_after_max_failures_from_any_ip() {
|
||||
let guard = LoginGuard::from_config(&config());
|
||||
let t0 = Instant::now();
|
||||
assert!(
|
||||
guard
|
||||
.record_failure_at("a@x.test", ip("192.0.2.1"), t0)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
guard
|
||||
.record_failure_at("a@x.test", ip("192.0.2.2"), t0)
|
||||
.is_none()
|
||||
);
|
||||
let locked = guard
|
||||
.record_failure_at("A@X.test ", ip("192.0.2.3"), t0)
|
||||
.expect("third failure locks the account");
|
||||
assert_eq!(locked.scope, LockScope::Account);
|
||||
assert_eq!(locked.retry_after, Duration::from_secs(10));
|
||||
|
||||
let blocked = guard
|
||||
.check_at("a@x.test", ip("198.51.100.9"), t0 + Duration::from_secs(1))
|
||||
.unwrap_err();
|
||||
assert_eq!(blocked.scope, LockScope::Account);
|
||||
assert_eq!(blocked.retry_after_secs(), 9);
|
||||
assert!(
|
||||
guard
|
||||
.check_at("other@x.test", ip("198.51.100.9"), t0)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
guard
|
||||
.check_at("a@x.test", ip("198.51.100.9"), t0 + Duration::from_secs(10))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_lockouts_back_off_exponentially_up_to_the_cap() {
|
||||
let guard = LoginGuard::from_config(&config());
|
||||
let mut now = Instant::now();
|
||||
let mut durations = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let mut lock = None;
|
||||
for _ in 0..3 {
|
||||
lock = guard.record_failure_at("a@x.test", ip("192.0.2.1"), now);
|
||||
}
|
||||
let lock = lock.expect("lockout");
|
||||
durations.push(lock.retry_after.as_secs());
|
||||
now += lock.retry_after;
|
||||
}
|
||||
assert_eq!(durations, vec![10, 20, 40, 40]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_clears_account_but_not_ip_history() {
|
||||
let guard = LoginGuard::from_config(&config());
|
||||
let t0 = Instant::now();
|
||||
let attacker = ip("203.0.113.5");
|
||||
for n in 0..4 {
|
||||
guard.record_failure_at(&format!("victim{n}@x.test"), attacker, t0);
|
||||
}
|
||||
guard.record_failure_at("a@x.test", attacker, t0);
|
||||
guard.record_success("a@x.test");
|
||||
// 5th failure from the same IP (different accounts) locks the IP.
|
||||
assert!(guard.check_at("new@x.test", attacker, t0).is_err());
|
||||
assert_eq!(
|
||||
guard
|
||||
.check_at("new@x.test", attacker, t0)
|
||||
.unwrap_err()
|
||||
.scope,
|
||||
LockScope::Ip
|
||||
);
|
||||
assert_eq!(guard.tracked_keys().0, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_outside_the_window_do_not_accumulate() {
|
||||
let guard = LoginGuard::from_config(&config());
|
||||
let t0 = Instant::now();
|
||||
guard.record_failure_at("a@x.test", ip("192.0.2.1"), t0);
|
||||
guard.record_failure_at("a@x.test", ip("192.0.2.1"), t0);
|
||||
let later = t0 + Duration::from_secs(61);
|
||||
assert!(
|
||||
guard
|
||||
.record_failure_at("a@x.test", ip("192.0.2.1"), later)
|
||||
.is_none()
|
||||
);
|
||||
assert!(guard.check_at("a@x.test", ip("192.0.2.1"), later).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exempt_networks_skip_only_the_ip_lockout() {
|
||||
let guard = LoginGuard::from_config(&LoginRateLimitConfig {
|
||||
exempt_networks: vec!["10.0.0.0/8".to_string()],
|
||||
ip_max_failures: 1,
|
||||
..config()
|
||||
});
|
||||
let t0 = Instant::now();
|
||||
let internal = ip("10.1.2.3");
|
||||
guard.record_failure_at("a@x.test", internal, t0);
|
||||
assert!(guard.check_at("b@x.test", internal, t0).is_ok());
|
||||
guard.record_failure_at("a@x.test", internal, t0);
|
||||
guard.record_failure_at("a@x.test", internal, t0);
|
||||
assert_eq!(
|
||||
guard.check_at("a@x.test", internal, t0).unwrap_err().scope,
|
||||
LockScope::Account
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_thresholds_and_disabled_guard_never_block() {
|
||||
let guard = LoginGuard::from_config(&LoginRateLimitConfig {
|
||||
account_max_failures: 0,
|
||||
ip_max_failures: 0,
|
||||
..config()
|
||||
});
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..50 {
|
||||
assert!(
|
||||
guard
|
||||
.record_failure_at("a@x.test", ip("192.0.2.1"), t0)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(guard.check_at("a@x.test", ip("192.0.2.1"), t0).is_ok());
|
||||
|
||||
let disabled = LoginGuard::from_config(&LoginRateLimitConfig {
|
||||
enabled: false,
|
||||
..config()
|
||||
});
|
||||
for _ in 0..50 {
|
||||
disabled.record_failure_at("a@x.test", ip("192.0.2.1"), t0);
|
||||
}
|
||||
assert!(disabled.check_at("a@x.test", ip("192.0.2.1"), t0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cap_bounds_memory() {
|
||||
let guard = LoginGuard::from_config(&LoginRateLimitConfig {
|
||||
max_tracked_keys: 100,
|
||||
ip_max_failures: 0,
|
||||
..config()
|
||||
});
|
||||
let t0 = Instant::now();
|
||||
for n in 0..500 {
|
||||
guard.record_failure_at(&format!("u{n}@x.test"), ip("192.0.2.1"), t0);
|
||||
}
|
||||
assert_eq!(guard.tracked_keys().0, 100);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod csrf;
|
||||
pub mod imap_auth;
|
||||
pub mod login_guard;
|
||||
pub mod middleware;
|
||||
pub mod session;
|
||||
pub mod user_data;
|
||||
+347
@@ -180,6 +180,125 @@ impl Default for SearchConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct LoginRateLimitConfig {
|
||||
/// Master switch for all login brute-force protection (per-IP token bucket
|
||||
/// and failed-attempt lockouts).
|
||||
#[serde(default = "default_login_rate_limit_enabled")]
|
||||
pub enabled: bool,
|
||||
/// Per-IP token bucket for POST /api/auth/login: maximum burst of attempts.
|
||||
#[serde(default = "default_login_ip_burst")]
|
||||
pub ip_burst: u32,
|
||||
/// Per-IP token bucket: one attempt is replenished every N seconds.
|
||||
#[serde(default = "default_login_ip_replenish_secs")]
|
||||
pub ip_replenish_secs: u64,
|
||||
/// Failed logins (any account) from one client IP inside `failure_window_secs`
|
||||
/// before that IP is locked out. 0 disables the per-IP lockout.
|
||||
#[serde(default = "default_login_ip_max_failures")]
|
||||
pub ip_max_failures: u32,
|
||||
/// Failed logins for one account (case-insensitive e-mail, any IP) inside
|
||||
/// `failure_window_secs` before that account is locked out. 0 disables it.
|
||||
#[serde(default = "default_login_account_max_failures")]
|
||||
pub account_max_failures: u32,
|
||||
/// Window in which failed attempts are counted.
|
||||
#[serde(default = "default_login_failure_window_secs")]
|
||||
pub failure_window_secs: u64,
|
||||
/// Duration of the first lockout. Each consecutive lockout of the same key
|
||||
/// doubles the duration, up to `max_lockout_secs`.
|
||||
#[serde(default = "default_login_lockout_secs")]
|
||||
pub lockout_secs: u64,
|
||||
/// Upper bound for the exponential lockout back-off.
|
||||
#[serde(default = "default_login_max_lockout_secs")]
|
||||
pub max_lockout_secs: u64,
|
||||
/// Client IPs/CIDRs that never receive a per-IP lockout (for example an
|
||||
/// internal monitoring probe). Account lockouts still apply to them.
|
||||
#[serde(default)]
|
||||
pub exempt_networks: Vec<String>,
|
||||
/// Memory cap for each tracking table (accounts and IPs).
|
||||
#[serde(default = "default_login_max_tracked_keys")]
|
||||
pub max_tracked_keys: usize,
|
||||
}
|
||||
|
||||
impl Default for LoginRateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_login_rate_limit_enabled(),
|
||||
ip_burst: default_login_ip_burst(),
|
||||
ip_replenish_secs: default_login_ip_replenish_secs(),
|
||||
ip_max_failures: default_login_ip_max_failures(),
|
||||
account_max_failures: default_login_account_max_failures(),
|
||||
failure_window_secs: default_login_failure_window_secs(),
|
||||
lockout_secs: default_login_lockout_secs(),
|
||||
max_lockout_secs: default_login_max_lockout_secs(),
|
||||
exempt_networks: Vec::new(),
|
||||
max_tracked_keys: default_login_max_tracked_keys(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LoginRateLimitConfig {
|
||||
pub fn parsed_exempt_networks(&self) -> Result<Vec<IpNet>, String> {
|
||||
self.exempt_networks
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
parse_ip_or_network(entry).map_err(|e| {
|
||||
format!(
|
||||
"invalid login_rate_limit.exempt_networks entry '{}': {e}",
|
||||
entry.trim()
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
if self.ip_burst == 0 || self.ip_burst > 1000 {
|
||||
return Err("LOGIN_RATE_LIMIT__IP_BURST must be between 1 and 1000".to_string());
|
||||
}
|
||||
if self.ip_replenish_secs == 0 || self.ip_replenish_secs > 3600 {
|
||||
return Err(
|
||||
"LOGIN_RATE_LIMIT__IP_REPLENISH_SECS must be between 1 and 3600".to_string(),
|
||||
);
|
||||
}
|
||||
if self.failure_window_secs == 0 || self.failure_window_secs > 86_400 {
|
||||
return Err(
|
||||
"LOGIN_RATE_LIMIT__FAILURE_WINDOW_SECS must be between 1 and 86400".to_string(),
|
||||
);
|
||||
}
|
||||
if self.lockout_secs == 0 || self.lockout_secs > 86_400 {
|
||||
return Err("LOGIN_RATE_LIMIT__LOCKOUT_SECS must be between 1 and 86400".to_string());
|
||||
}
|
||||
if self.max_lockout_secs < self.lockout_secs || self.max_lockout_secs > 7 * 86_400 {
|
||||
return Err(
|
||||
"LOGIN_RATE_LIMIT__MAX_LOCKOUT_SECS must be >= lockout_secs and <= 604800"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if self.max_tracked_keys < 100 || self.max_tracked_keys > 10_000_000 {
|
||||
return Err(
|
||||
"LOGIN_RATE_LIMIT__MAX_TRACKED_KEYS must be between 100 and 10000000".to_string(),
|
||||
);
|
||||
}
|
||||
self.parsed_exempt_networks()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ip_or_network(entry: &str) -> Result<IpNet, String> {
|
||||
let trimmed = entry.trim();
|
||||
if let Ok(network) = trimmed.parse::<IpNet>() {
|
||||
return Ok(network);
|
||||
}
|
||||
let ip = trimmed.parse::<IpAddr>().map_err(|e| e.to_string())?;
|
||||
Ok(match ip {
|
||||
IpAddr::V4(value) => IpNet::V4(Ipv4Net::new(value, 32).expect("valid IPv4 prefix")),
|
||||
IpAddr::V6(value) => IpNet::V6(Ipv6Net::new(value, 128).expect("valid IPv6 prefix")),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+24
-9
@@ -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)] = &[(
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
51,
|
||||
include_str!(
|
||||
"../../migrations/sqlite/V051__adaptive_email_colors_and_message_loading_mode.sql"
|
||||
),
|
||||
)];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
),
|
||||
(52, include_str!("../../migrations/sqlite/V052__snooze.sql")),
|
||||
];
|
||||
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
|
||||
(
|
||||
51,
|
||||
include_str!("../../migrations/mysql/V051__adaptive_email_colors_and_message_loading_mode.sql"),
|
||||
)];
|
||||
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
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"),
|
||||
)];
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Snoozing a message hides it from its folder listing until a chosen wake
|
||||
//! time. This is purely local cache state: nothing is moved on the real IMAP
|
||||
//! server, so snoozing never risks the actual mailbox. The client is
|
||||
//! responsible for excluding a still-sleeping message from the folder it
|
||||
//! renders and for showing the "Snoozed" view built from [`list_snoozed`].
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::db::pool::{Connection, params};
|
||||
|
||||
pub fn now_epoch() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.min(i64::MAX as u64) as i64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SnoozedMessage {
|
||||
pub id: String,
|
||||
pub message_uid: u32,
|
||||
pub message_folder: String,
|
||||
pub wake_at: i64,
|
||||
pub subject: String,
|
||||
pub from_name: String,
|
||||
pub from_address: String,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
/// Snoozes `(folder, uid)` until `wake_at` (epoch seconds), replacing any
|
||||
/// existing snooze for the same message. Returns the new snooze id.
|
||||
pub fn snooze_message(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
wake_at: i64,
|
||||
) -> Result<String, String> {
|
||||
if folder.trim().is_empty() {
|
||||
return Err("folder must not be empty".to_string());
|
||||
}
|
||||
if wake_at <= now_epoch() {
|
||||
return Err("snooze wake time must be in the future".to_string());
|
||||
}
|
||||
conn.execute(
|
||||
crate::dbq!(db_snoozed_messages::Q_002),
|
||||
params![uid, folder],
|
||||
)
|
||||
.map_err(|e| format!("failed to clear existing snooze: {e}"))?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
crate::dbq!(db_snoozed_messages::Q_001),
|
||||
params![&id, uid, folder, wake_at],
|
||||
)
|
||||
.map_err(|e| format!("failed to snooze message: {e}"))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Removes any snooze on `(folder, uid)`. Returns whether a row was removed.
|
||||
pub fn unsnooze_message(conn: &Connection, folder: &str, uid: u32) -> Result<bool, String> {
|
||||
let changed = conn
|
||||
.execute(
|
||||
crate::dbq!(db_snoozed_messages::Q_002),
|
||||
params![uid, folder],
|
||||
)
|
||||
.map_err(|e| format!("failed to unsnooze message: {e}"))?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// Every snoozed message (woken or not), joined with the cached message
|
||||
/// metadata needed to render the "Snoozed" view, ordered by wake time.
|
||||
pub fn list_snoozed(conn: &Connection) -> Result<Vec<SnoozedMessage>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(crate::dbq!(db_snoozed_messages::Q_003))
|
||||
.map_err(|e| format!("failed to prepare snoozed-messages query: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SnoozedMessage {
|
||||
id: row.get(0)?,
|
||||
message_uid: row.get::<_, i64>(1)?.max(0) as u32,
|
||||
message_folder: row.get(2)?,
|
||||
wake_at: row.get(3)?,
|
||||
subject: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
|
||||
from_name: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
|
||||
from_address: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||
snippet: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("failed to query snoozed messages: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("failed to read snoozed message row: {e}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The UIDs in `folder` that are still asleep as of `now` (i.e. due to be
|
||||
/// hidden from that folder's listing right now). Cheap enough for the client
|
||||
/// to call once per folder open; no IMAP or background work is involved.
|
||||
pub fn list_sleeping_uids_in_folder(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
now: i64,
|
||||
) -> Result<Vec<u32>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(crate::dbq!(db_snoozed_messages::Q_004))
|
||||
.map_err(|e| format!("failed to prepare sleeping-uids query: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![folder, now], |row| {
|
||||
Ok(row.get::<_, i64>(0)?.max(0) as u32)
|
||||
})
|
||||
.map_err(|e| format!("failed to query sleeping uids: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("failed to read sleeping uid row: {e}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
fn insert_cached_message(conn: &Connection, folder: &str, uid: u32, subject: &str) {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folders (name) VALUES (?1)",
|
||||
params![folder],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO messages (uid, folder, subject, date, size) VALUES (?1, ?2, ?3, '', 0)",
|
||||
params![uid, folder, subject],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snoozing_rejects_a_wake_time_in_the_past() {
|
||||
let conn = open_test_db();
|
||||
let error = snooze_message(&conn, "INBOX", 1, now_epoch() - 10).unwrap_err();
|
||||
assert!(error.contains("future"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_snoozing_the_same_message_replaces_the_wake_time() {
|
||||
let conn = open_test_db();
|
||||
let future = now_epoch() + 3600;
|
||||
let later = now_epoch() + 7200;
|
||||
snooze_message(&conn, "INBOX", 42, future).unwrap();
|
||||
snooze_message(&conn, "INBOX", 42, later).unwrap();
|
||||
let snoozed = list_snoozed(&conn).unwrap();
|
||||
assert_eq!(snoozed.len(), 1);
|
||||
assert_eq!(snoozed[0].wake_at, later);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnoozing_removes_the_entry() {
|
||||
let conn = open_test_db();
|
||||
snooze_message(&conn, "INBOX", 7, now_epoch() + 60).unwrap();
|
||||
assert!(unsnooze_message(&conn, "INBOX", 7).unwrap());
|
||||
assert!(list_snoozed(&conn).unwrap().is_empty());
|
||||
assert!(!unsnooze_message(&conn, "INBOX", 7).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_snoozed_carries_cached_message_metadata() {
|
||||
let conn = open_test_db();
|
||||
insert_cached_message(&conn, "INBOX", 11, "Quarterly report");
|
||||
snooze_message(&conn, "INBOX", 11, now_epoch() + 60).unwrap();
|
||||
let snoozed = list_snoozed(&conn).unwrap();
|
||||
assert_eq!(snoozed.len(), 1);
|
||||
assert_eq!(snoozed[0].subject, "Quarterly report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleeping_uids_exclude_already_woken_snoozes() {
|
||||
let conn = open_test_db();
|
||||
let now = now_epoch();
|
||||
snooze_message(&conn, "INBOX", 1, now + 60).unwrap();
|
||||
snooze_message(&conn, "INBOX", 2, now + 120).unwrap();
|
||||
let sleeping = list_sleeping_uids_in_folder(&conn, "INBOX", now).unwrap();
|
||||
assert_eq!(sleeping.len(), 2);
|
||||
let sleeping_later = list_sleeping_uids_in_folder(&conn, "INBOX", now + 90).unwrap();
|
||||
assert_eq!(sleeping_later, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleeping_uids_are_scoped_to_their_folder() {
|
||||
let conn = open_test_db();
|
||||
let now = now_epoch();
|
||||
snooze_message(&conn, "INBOX", 1, now + 60).unwrap();
|
||||
snooze_message(&conn, "Archive", 2, now + 60).unwrap();
|
||||
let sleeping = list_sleeping_uids_in_folder(&conn, "INBOX", now).unwrap();
|
||||
assert_eq!(sleeping, vec![1]);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Memory guards for assembling messages that carry attachments.
|
||||
//!
|
||||
//! Attachments are streamed to blob storage on upload, but sending a message or
|
||||
//! saving an IMAP draft snapshot has to load every attachment into memory, and
|
||||
//! the MIME builder then keeps several copies (raw bytes, base64 body, final
|
||||
//! RFC 5322 text, the copy appended to the Sent folder). Two guards keep that
|
||||
//! bounded:
|
||||
//!
|
||||
//! * [`ensure_attachments_fit`] rejects an attachment set that cannot fit
|
||||
//! `max_message_size_mb` *before* any blob is read, using the sizes already
|
||||
//! stored in the database (and while an upload is still streaming).
|
||||
//! * [`MessageBuildLimiter`] caps how many requests may hold attachment data in
|
||||
//! memory at the same time (`max_concurrent_message_builds`). Excess requests
|
||||
//! wait up to `message_build_wait_secs` and then get `503`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
|
||||
use crate::db::drafts::DraftAttachment;
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Allowance for the MIME headers and boundary lines of one attachment part.
|
||||
const PER_PART_OVERHEAD_BYTES: u64 = 512;
|
||||
|
||||
/// Size of `raw_bytes` of attachment data spread over `parts` MIME parts after
|
||||
/// base64 encoding (4 bytes per 3, plus a CRLF every 76 characters).
|
||||
pub fn encoded_size_estimate(raw_bytes: u64, parts: usize) -> u64 {
|
||||
let base64 = raw_bytes.div_ceil(3).saturating_mul(4);
|
||||
let line_breaks = base64.div_ceil(76).saturating_mul(2);
|
||||
base64
|
||||
.saturating_add(line_breaks)
|
||||
.saturating_add((parts as u64).saturating_mul(PER_PART_OVERHEAD_BYTES))
|
||||
}
|
||||
|
||||
/// Sum of the sizes recorded for stored draft attachments.
|
||||
pub fn draft_attachments_total(records: &[DraftAttachment]) -> u64 {
|
||||
records
|
||||
.iter()
|
||||
.map(|record| u64::try_from(record.size).unwrap_or(0))
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn mib(bytes: u64) -> f64 {
|
||||
bytes as f64 / 1024.0 / 1024.0
|
||||
}
|
||||
|
||||
/// Fails with `413` when `raw_bytes` of attachments (in `parts` parts) would
|
||||
/// exceed `limit_bytes` once MIME-encoded. A limit of 0 disables the check.
|
||||
pub fn ensure_attachments_fit(
|
||||
limit_bytes: usize,
|
||||
raw_bytes: u64,
|
||||
parts: usize,
|
||||
) -> Result<(), AppError> {
|
||||
if limit_bytes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let estimate = encoded_size_estimate(raw_bytes, parts);
|
||||
if estimate > limit_bytes as u64 {
|
||||
return Err(AppError::PayloadTooLarge(format!(
|
||||
"Attachments total {:.2} MiB ({:.2} MiB after MIME encoding); the message size limit is {:.2} MiB",
|
||||
mib(raw_bytes),
|
||||
mib(estimate),
|
||||
mib(limit_bytes as u64)
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bounds the number of requests that hold attachment data in memory at once.
|
||||
#[derive(Debug)]
|
||||
pub struct MessageBuildLimiter {
|
||||
semaphore: Arc<Semaphore>,
|
||||
wait: Duration,
|
||||
}
|
||||
|
||||
impl MessageBuildLimiter {
|
||||
pub fn new(max_concurrent: usize, wait: Duration) -> Self {
|
||||
Self {
|
||||
semaphore: Arc::new(Semaphore::new(max_concurrent.max(1))),
|
||||
wait,
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits (first come, first served) for a free slot. Keep the returned
|
||||
/// permit alive for as long as the attachment data is in memory.
|
||||
pub async fn acquire(&self) -> Result<OwnedSemaphorePermit, AppError> {
|
||||
match tokio::time::timeout(self.wait, self.semaphore.clone().acquire_owned()).await {
|
||||
Ok(Ok(permit)) => Ok(permit),
|
||||
Ok(Err(_closed)) => Err(AppError::InternalError(
|
||||
"message build limiter is closed".to_string(),
|
||||
)),
|
||||
Err(_elapsed) => {
|
||||
tracing::warn!(
|
||||
wait_secs = self.wait.as_secs_f32(),
|
||||
"too many messages with attachments are being assembled; request rejected"
|
||||
);
|
||||
Err(AppError::ServiceUnavailable(
|
||||
"The server is busy assembling other messages with attachments; try again in a moment"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn estimate_accounts_for_base64_line_breaks_and_part_overhead() {
|
||||
// 57 raw bytes -> 76 base64 characters -> one CRLF -> +512 part overhead.
|
||||
assert_eq!(encoded_size_estimate(57, 1), 76 + 2 + 512);
|
||||
assert_eq!(encoded_size_estimate(0, 0), 0);
|
||||
let raw = 9_000_000u64;
|
||||
let estimate = encoded_size_estimate(raw, 1);
|
||||
assert!(estimate > raw * 4 / 3);
|
||||
assert!(estimate < raw * 4 / 3 + raw / 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachments_over_the_message_limit_are_rejected_before_loading() {
|
||||
let limit = 10 * 1024 * 1024;
|
||||
assert!(ensure_attachments_fit(limit, 7_000_000, 2).is_ok());
|
||||
let error = ensure_attachments_fit(limit, 8_000_000, 2).unwrap_err();
|
||||
assert!(matches!(error, AppError::PayloadTooLarge(_)));
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("message size limit is 10.00 MiB")
|
||||
);
|
||||
// The same bytes split over many parts cost extra overhead.
|
||||
assert!(ensure_attachments_fit(limit, 7_300_000, 1).is_ok());
|
||||
assert!(ensure_attachments_fit(limit, 7_300_000, 5_000).is_err());
|
||||
assert!(ensure_attachments_fit(0, u64::MAX, 1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_ignore_invalid_negative_sizes() {
|
||||
let record = |size: i64| DraftAttachment {
|
||||
id: "a".to_string(),
|
||||
draft_id: "d".to_string(),
|
||||
filename: "f".to_string(),
|
||||
content_type: "application/octet-stream".to_string(),
|
||||
size,
|
||||
storage_key: "k".to_string(),
|
||||
checksum_sha256: None,
|
||||
created_at: String::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
draft_attachments_total(&[record(10), record(-5), record(32)]),
|
||||
42
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn limiter_queues_then_rejects_when_slots_stay_busy() {
|
||||
let limiter = MessageBuildLimiter::new(1, Duration::from_millis(60));
|
||||
let first = limiter.acquire().await.expect("first slot");
|
||||
|
||||
let error = limiter.acquire().await.unwrap_err();
|
||||
assert!(matches!(error, AppError::ServiceUnavailable(_)));
|
||||
|
||||
drop(first);
|
||||
let _second = limiter
|
||||
.acquire()
|
||||
.await
|
||||
.expect("slot is available again after the permit is dropped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn limiter_hands_a_slot_to_a_waiting_request() {
|
||||
let limiter = Arc::new(MessageBuildLimiter::new(1, Duration::from_secs(5)));
|
||||
let first = limiter.acquire().await.unwrap();
|
||||
let waiting = {
|
||||
let limiter = limiter.clone();
|
||||
tokio::spawn(async move { limiter.acquire().await.map(|_| ()) })
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
drop(first);
|
||||
assert!(waiting.await.unwrap().is_ok());
|
||||
}
|
||||
}
|
||||
@@ -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 = ?)";
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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<Arc<AppConfig>>,
|
||||
Extension(storage): Extension<Arc<dyn BlobStorage>>,
|
||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||
Extension(build_limiter): Extension<Arc<MessageBuildLimiter>>,
|
||||
AxumPath(draft_id): AxumPath<String>,
|
||||
Json(req): Json<AttachMessageSourceRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -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();
|
||||
|
||||
+352
-5
@@ -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::<ConnectInfo<SocketAddr>>()
|
||||
.map(|info| info.0.ip())
|
||||
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
/// Whether issued/cleared session cookies carry the `Secure` attribute.
|
||||
///
|
||||
/// `cookie_secure = "auto"` (default) marks cookies `Secure` only when the
|
||||
/// browser actually reached RustBox over HTTPS, so plain-HTTP installations
|
||||
/// keep working: a `Secure` cookie sent over HTTP is discarded by browsers and
|
||||
/// the user could never stay logged in.
|
||||
pub(crate) fn cookie_secure(
|
||||
config: &AppConfig,
|
||||
extensions: &axum::http::Extensions,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> bool {
|
||||
match config.cookie_secure.trim().to_ascii_lowercase().as_str() {
|
||||
"always" => true,
|
||||
"never" => false,
|
||||
_ => request_is_https(config, extensions, headers),
|
||||
}
|
||||
}
|
||||
|
||||
/// RustBox itself serves plain HTTP, so HTTPS can only be terminated in front
|
||||
/// of it. A scheme reported by a trusted reverse proxy (`X-Forwarded-Proto`,
|
||||
/// `Forwarded: proto=`, `X-Forwarded-Ssl`) wins; otherwise the scheme of the
|
||||
/// browser-supplied `Origin`/`Referer` is used. A forged header can only change
|
||||
/// the cookies issued to the client that forged it.
|
||||
fn request_is_https(
|
||||
config: &AppConfig,
|
||||
extensions: &axum::http::Extensions,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> bool {
|
||||
let peer = peer_ip(extensions);
|
||||
let peer_is_trusted_proxy = config
|
||||
.parsed_trusted_proxies()
|
||||
.iter()
|
||||
.any(|proxy| proxy.contains(&peer));
|
||||
if peer_is_trusted_proxy && let Some(proto) = forwarded_proto(headers) {
|
||||
return proto == "https";
|
||||
}
|
||||
header_url_is_https(headers, "origin") || header_url_is_https(headers, "referer")
|
||||
}
|
||||
|
||||
fn forwarded_proto(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
|
||||
|
||||
if let Some(proto) = header("x-forwarded-proto")
|
||||
.and_then(|value| value.split(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(proto.to_ascii_lowercase());
|
||||
}
|
||||
if let Some(forwarded) = header("forwarded") {
|
||||
for element in forwarded.split(',') {
|
||||
for pair in element.split(';') {
|
||||
if let Some((key, value)) = pair.split_once('=')
|
||||
&& key.trim().eq_ignore_ascii_case("proto")
|
||||
{
|
||||
return Some(value.trim().trim_matches('"').to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if header("x-forwarded-ssl").is_some_and(|value| value.trim().eq_ignore_ascii_case("on")) {
|
||||
return Some("https".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn header_url_is_https(headers: &axum::http::HeaderMap, name: &str) -> bool {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.trim().get(..8))
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|
||||
}
|
||||
|
||||
fn too_many_login_attempts(blocked: LoginBlocked) -> Response {
|
||||
let retry_after = blocked.retry_after_secs();
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[
|
||||
("content-type", "application/json".to_string()),
|
||||
("retry-after", retry_after.to_string()),
|
||||
],
|
||||
serde_json::json!({
|
||||
"error": {
|
||||
"code": "TOO_MANY_LOGIN_ATTEMPTS",
|
||||
"message": format!(
|
||||
"Too many failed login attempts. Try again in {}.",
|
||||
human_wait(retry_after)
|
||||
),
|
||||
"status": 429,
|
||||
"retryAfterSecs": retry_after
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn human_wait(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs} s")
|
||||
} else {
|
||||
format!("{} min", secs.div_ceil(60))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
Extension(store): Extension<Arc<SessionStore>>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
extensions: axum::http::Extensions,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Response {
|
||||
@@ -192,6 +318,20 @@ pub async fn login(
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let login_guard = extensions.get::<Arc<LoginGuard>>().cloned();
|
||||
let client_ip = login_client_ip(&config, &extensions, &headers);
|
||||
if let Some(guard) = login_guard.as_deref()
|
||||
&& let Err(blocked) = guard.check(&body.email, client_ip)
|
||||
{
|
||||
tracing::warn!(
|
||||
source_ip = %client_ip,
|
||||
scope = blocked.scope.as_str(),
|
||||
retry_after_secs = blocked.retry_after_secs(),
|
||||
"login refused: brute-force lockout active"
|
||||
);
|
||||
return too_many_login_attempts(blocked);
|
||||
}
|
||||
|
||||
let smtp_host = config
|
||||
.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<SessionState>) -> Respons
|
||||
pub async fn list_accounts(
|
||||
Extension(store): Extension<Arc<SessionStore>>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
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<Arc<SessionStore>>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path(account_id): Path<String>,
|
||||
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<Arc<SessionStore>>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
extensions: axum::http::Extensions,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let browser_id = extract_browser_id(&headers);
|
||||
|
||||
let mut cookies_to_clear: Vec<String> = Vec::new();
|
||||
|
||||
let secure = config.environment != "development";
|
||||
let secure = cookie_secure(&config, &extensions, &headers);
|
||||
let same_site = same_site_policy(&config);
|
||||
|
||||
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::<SocketAddr>(
|
||||
"198.51.100.7:50000".parse().unwrap(),
|
||||
));
|
||||
login(
|
||||
Extension(store.clone()),
|
||||
Extension(config.clone()),
|
||||
extensions,
|
||||
axum::http::HeaderMap::new(),
|
||||
Json(LoginRequest {
|
||||
email: email.to_string(),
|
||||
password: "wrong".to_string(),
|
||||
remember: false,
|
||||
browser_id: None,
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
attempt("victim@example.com").await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
attempt("Victim@Example.com").await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
|
||||
let locked = attempt("victim@example.com").await;
|
||||
assert_eq!(locked.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
let retry_after: u64 = locked
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!((1..=120).contains(&retry_after));
|
||||
assert_eq!(connections.load(Ordering::SeqCst), 2);
|
||||
|
||||
// Another mailbox from the same IP is still allowed to try.
|
||||
assert_eq!(
|
||||
attempt("other@example.com").await.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(connections.load(Ordering::SeqCst), 3);
|
||||
imap.abort();
|
||||
}
|
||||
|
||||
fn request_parts(
|
||||
peer: &str,
|
||||
headers: &[(&'static str, &'static str)],
|
||||
) -> (axum::http::Extensions, axum::http::HeaderMap) {
|
||||
let mut extensions = axum::http::Extensions::new();
|
||||
extensions.insert(ConnectInfo::<SocketAddr>(peer.parse().unwrap()));
|
||||
let mut map = axum::http::HeaderMap::new();
|
||||
for &(name, value) in headers {
|
||||
map.append(name, axum::http::HeaderValue::from_static(value));
|
||||
}
|
||||
(extensions, map)
|
||||
}
|
||||
|
||||
fn secure_for(
|
||||
config: &AppConfig,
|
||||
peer: &str,
|
||||
headers: &[(&'static str, &'static str)],
|
||||
) -> bool {
|
||||
let (extensions, headers) = request_parts(peer, headers);
|
||||
cookie_secure(config, &extensions, &headers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_cookie_secure_follows_the_real_request_scheme() {
|
||||
let mut config = test_config("production", None);
|
||||
config.trusted_proxies = Some("127.0.0.1,10.0.0.0/8".to_string());
|
||||
let client = "192.0.2.10:5000";
|
||||
let proxy = "10.0.0.2:5000";
|
||||
|
||||
// Plain HTTP install, even in production: no Secure flag, login keeps working.
|
||||
assert!(!secure_for(&config, client, &[]));
|
||||
assert!(!secure_for(
|
||||
&config,
|
||||
client,
|
||||
&[("origin", "http://mail.example.com")]
|
||||
));
|
||||
// Browser on HTTPS (TLS terminated in front of RustBox).
|
||||
assert!(secure_for(
|
||||
&config,
|
||||
client,
|
||||
&[("origin", "https://mail.example.com")]
|
||||
));
|
||||
assert!(secure_for(
|
||||
&config,
|
||||
"127.0.0.1:5000",
|
||||
&[("referer", "HTTPS://mail.example.com/inbox")]
|
||||
));
|
||||
// Trusted reverse proxy reports the scheme explicitly and wins.
|
||||
assert!(secure_for(
|
||||
&config,
|
||||
proxy,
|
||||
&[("x-forwarded-proto", "https")]
|
||||
));
|
||||
assert!(secure_for(
|
||||
&config,
|
||||
proxy,
|
||||
&[("forwarded", "for=192.0.2.1;proto=https")]
|
||||
));
|
||||
assert!(secure_for(&config, proxy, &[("x-forwarded-ssl", "on")]));
|
||||
assert!(!secure_for(
|
||||
&config,
|
||||
proxy,
|
||||
&[
|
||||
("x-forwarded-proto", "http"),
|
||||
("origin", "https://mail.example.com")
|
||||
]
|
||||
));
|
||||
// Untrusted peers cannot assert the scheme through proxy headers.
|
||||
assert!(!secure_for(
|
||||
&config,
|
||||
client,
|
||||
&[("x-forwarded-proto", "https")]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_secure_can_be_forced_from_config() {
|
||||
let (extensions, headers) = request_parts("192.0.2.10:5000", &[]);
|
||||
let mut config = test_config("development", None);
|
||||
config.cookie_secure = "always".to_string();
|
||||
assert!(cookie_secure(&config, &extensions, &headers));
|
||||
|
||||
let (extensions, headers) =
|
||||
request_parts("192.0.2.10:5000", &[("origin", "https://mail.example.com")]);
|
||||
config.cookie_secure = "never".to_string();
|
||||
assert!(!cookie_secure(&config, &extensions, &headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issued_and_clearing_cookies_use_the_normalized_base_path() {
|
||||
for (base_path, expected_path) in [
|
||||
|
||||
+30
-5
@@ -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<Arc<AppConfig>>,
|
||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||
Extension(storage): Extension<Arc<dyn BlobStorage>>,
|
||||
Extension(build_limiter): Extension<Arc<MessageBuildLimiter>>,
|
||||
Json(req): Json<UpsertDraftRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<db::drafts::DraftAttachment>,
|
||||
) -> Result<Vec<AttachmentData>, 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)
|
||||
|
||||
@@ -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<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path((folder, uid)): Path<(String, u32)>,
|
||||
Json(body): Json<SnoozeMessageRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
let wake_at = match (body.wake_at, body.snooze_for_secs) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Specify either wake_at or snooze_for_secs, not both".to_string(),
|
||||
));
|
||||
}
|
||||
(Some(wake_at), None) => wake_at,
|
||||
(None, Some(secs)) => {
|
||||
if secs <= 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"snooze_for_secs must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
db::snooze::now_epoch().saturating_add(secs)
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Specify wake_at or snooze_for_secs".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
let id =
|
||||
db::snooze::snooze_message(&conn, &folder, uid, wake_at).map_err(AppError::BadRequest)?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "ok", "id": id, "wake_at": wake_at })).into_response())
|
||||
}
|
||||
|
||||
pub async fn unsnooze_message_handler(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path((folder, uid)): Path<(String, u32)>,
|
||||
) -> Result<Response, AppError> {
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
db::snooze::unsnooze_message(&conn, &folder, uid).map_err(AppError::InternalError)?;
|
||||
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
|
||||
}
|
||||
|
||||
/// All snoozed messages (woken or not), for the "Snoozed" view.
|
||||
pub async fn list_snoozed_handler(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
) -> Result<Response, AppError> {
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
let snoozed = db::snooze::list_snoozed(&conn).map_err(AppError::InternalError)?;
|
||||
Ok(Json(serde_json::json!({ "messages": snoozed })).into_response())
|
||||
}
|
||||
|
||||
/// The UIDs in `folder` that are still snoozed as of now, so the client can
|
||||
/// hide those rows from the folder it is rendering. No IMAP or background
|
||||
/// work is involved; a message simply stops being returned here once its
|
||||
/// wake time passes.
|
||||
pub async fn list_sleeping_uids(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Path(folder): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||
let uids = db::snooze::list_sleeping_uids_in_folder(&conn, &folder, db::snooze::now_epoch())
|
||||
.map_err(AppError::InternalError)?;
|
||||
Ok(Json(serde_json::json!({ "uids": uids })).into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sync_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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<i64>,
|
||||
#[serde(default)]
|
||||
pub(crate) snooze_for_secs: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ListMessagesResponse {
|
||||
pub(crate) messages: Vec<MessageSummary>,
|
||||
|
||||
+41
-5
@@ -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<AppConfig>, services: RouterServic
|
||||
} = services;
|
||||
let system_diagnostics_telemetry =
|
||||
Arc::new(system_diagnostics::SystemDiagnosticsTelemetry::new());
|
||||
let build_limiter = Arc::new(MessageBuildLimiter::new(
|
||||
config.max_concurrent_message_builds,
|
||||
Duration::from_secs(config.message_build_wait_secs),
|
||||
));
|
||||
let undo_send_registry = Arc::new(crate::undo_send::UndoSendRegistry::new());
|
||||
let login_rate_limit = &config.login_rate_limit;
|
||||
let login_guard = Arc::new(LoginGuard::from_config(login_rate_limit));
|
||||
|
||||
// Only the credential-checking endpoint is throttled; the public policy GET
|
||||
// used by the login form must not consume the per-IP login budget.
|
||||
let login_route = Router::new().route("/login", post(auth::login));
|
||||
let login_route = if login_rate_limit.enabled {
|
||||
let governor_conf = GovernorConfigBuilder::default()
|
||||
.key_extractor(ProxyAwareIpExtractor {
|
||||
trusted_proxies: config.parsed_trusted_proxies(),
|
||||
})
|
||||
.period(Duration::from_secs(12))
|
||||
.burst_size(5)
|
||||
.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<AppConfig>, 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<AppConfig>, 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<AppConfig>, 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<AppConfig>, 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());
|
||||
|
||||
|
||||
+211
-58
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub undo_window_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn send_message_handler(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(smtp_client): Extension<Arc<dyn SmtpClient>>,
|
||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||
Extension(storage): Extension<Arc<dyn BlobStorage>>,
|
||||
Extension(build_limiter): Extension<Arc<MessageBuildLimiter>>,
|
||||
Extension(undo_registry): Extension<Arc<UndoSendRegistry>>,
|
||||
Json(req): Json<SendRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<String, AppError> {
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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<Arc<UndoSendRegistry>>,
|
||||
Path(pending_id): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
match undo_registry.status(&pending_id) {
|
||||
None => Err(AppError::NotFound(
|
||||
"No pending send with that id".to_string(),
|
||||
)),
|
||||
Some(PendingOutcome::Pending) => {
|
||||
if undo_registry.cancel(&pending_id) {
|
||||
Ok(Json(serde_json::json!({ "status": "cancelled" })).into_response())
|
||||
} else {
|
||||
Err(AppError::BadRequest(
|
||||
"Too late to cancel; the message is already being sent".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Some(_) => Err(AppError::BadRequest(
|
||||
"Too late to cancel; the message is already being sent".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /messages/send/{pending_send_id}`: the current state of a staged
|
||||
/// send, for the client to poll once its Undo Send countdown ends.
|
||||
pub async fn pending_send_status(
|
||||
Extension(undo_registry): Extension<Arc<UndoSendRegistry>>,
|
||||
Path(pending_id): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
match undo_registry.status(&pending_id) {
|
||||
Some(outcome) => Ok(Json(outcome).into_response()),
|
||||
None => Err(AppError::NotFound(
|
||||
"No pending send with that id".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft attachment metadata, rejected with 413 when the stored sizes alone
|
||||
/// already exceed the message size limit. No blob data is read here.
|
||||
fn draft_attachment_records(
|
||||
config: &AppConfig,
|
||||
user_hash: &str,
|
||||
draft_id: &str,
|
||||
storage: &dyn BlobStorage,
|
||||
) -> Result<Vec<AttachmentData>, AppError> {
|
||||
let conn = db::pool::open_user_db(data_dir, user_hash)
|
||||
) -> Result<Vec<db::drafts::DraftAttachment>, 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<db::drafts::DraftAttachment>,
|
||||
) -> Result<Vec<AttachmentData>, AppError> {
|
||||
let mut attachments = Vec::with_capacity(records.len());
|
||||
for att in records {
|
||||
let data = storage::read_all(storage, &att.storage_key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -86,6 +86,12 @@ fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
|
||||
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<Body> {
|
||||
let boundary = "rustbox-test-boundary";
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(
|
||||
format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(payload);
|
||||
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||
let mut req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header(
|
||||
"content-type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.header("x-requested-with", "XMLHttpRequest");
|
||||
for (name, value) in auth {
|
||||
req = req.header(*name, value.as_str());
|
||||
}
|
||||
req.body(Body::from(body)).unwrap()
|
||||
}
|
||||
|
||||
async fn error_code_of(response: axum::response::Response) -> String {
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
json["error"]["code"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_over_the_message_size_limit_is_rejected_while_streaming() {
|
||||
let static_dir = setup_static_dir();
|
||||
let data_dir = TempDir::new().unwrap();
|
||||
let mut cfg = (*test_config_with_imap(
|
||||
static_dir.path().to_str().unwrap(),
|
||||
data_dir.path().to_str().unwrap(),
|
||||
))
|
||||
.clone();
|
||||
cfg.max_attachment_size_mb = 10;
|
||||
cfg.max_message_size_mb = 1;
|
||||
let store = test_store();
|
||||
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
|
||||
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
||||
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
||||
let app = create_router(
|
||||
Arc::new(cfg),
|
||||
store,
|
||||
Arc::new(MockImapClient::new()),
|
||||
test_smtp_client(),
|
||||
test_search_engine(data_dir.path().to_str().unwrap()),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
let auth = auth_headers(&browser_id, &account_id, &token);
|
||||
|
||||
// A single 2 MiB file cannot fit a 1 MiB message even though it is below
|
||||
// the per-attachment limit.
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(multipart_upload_request(
|
||||
"/api/drafts/limit-draft/attachments",
|
||||
"big.bin",
|
||||
&vec![7u8; 2 * 1024 * 1024],
|
||||
&auth,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert_eq!(error_code_of(response).await, "PAYLOAD_TOO_LARGE");
|
||||
|
||||
// Two files that each fit but not together: the second one is refused.
|
||||
for (index, expected) in [(1, StatusCode::OK), (2, StatusCode::PAYLOAD_TOO_LARGE)] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(multipart_upload_request(
|
||||
"/api/drafts/limit-draft-2/attachments",
|
||||
&format!("part-{index}.bin"),
|
||||
&vec![1u8; 500_000],
|
||||
&auth,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected, "upload #{index}");
|
||||
}
|
||||
|
||||
let conn =
|
||||
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
||||
let stored = crate::db::drafts::get_draft_attachments(&conn, "limit-draft-2").unwrap();
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert!(
|
||||
crate::db::drafts::get_draft_attachments(&conn, "limit-draft")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_rejects_oversized_draft_attachments_before_reading_any_blob() {
|
||||
let static_dir = setup_static_dir();
|
||||
let data_dir = TempDir::new().unwrap();
|
||||
let config = test_config_with_smtp(
|
||||
static_dir.path().to_str().unwrap(),
|
||||
data_dir.path().to_str().unwrap(),
|
||||
);
|
||||
let store = test_store();
|
||||
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
|
||||
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
||||
let conn =
|
||||
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
||||
crate::db::drafts::upsert_draft(
|
||||
&conn,
|
||||
"huge-draft",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
"normal",
|
||||
"html",
|
||||
"none",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
// The recorded size is far above max_message_size_mb and the blob does not
|
||||
// exist: a 413 (not a 500 read error) proves nothing was loaded.
|
||||
crate::db::drafts::add_draft_attachment(
|
||||
&conn,
|
||||
&crate::db::drafts::NewDraftAttachment {
|
||||
id: "huge-att",
|
||||
draft_id: "huge-draft",
|
||||
filename: "huge.bin",
|
||||
content_type: "application/octet-stream",
|
||||
size: 200 * 1024 * 1024,
|
||||
storage_key: "missing/blob",
|
||||
checksum_sha256: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mock_smtp: Arc<dyn SmtpClient> = Arc::new(MockSmtpClient::new());
|
||||
let app = create_router(
|
||||
config,
|
||||
store,
|
||||
Arc::new(MockImapClient::new()),
|
||||
mock_smtp,
|
||||
test_search_engine(data_dir.path().to_str().unwrap()),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
let mut req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/messages/send")
|
||||
.header("x-requested-with", "XMLHttpRequest")
|
||||
.header("content-type", "application/json");
|
||||
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
|
||||
req = req.header(name, value);
|
||||
}
|
||||
let response = app
|
||||
.oneshot(
|
||||
req.body(Body::from(
|
||||
r#"{"to":["bob@example.com"],"subject":"Big","text_body":"x","draft_id":"huge-draft"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert_eq!(error_code_of(response).await, "PAYLOAD_TOO_LARGE");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sends_with_attachments_release_their_build_slot() {
|
||||
let static_dir = setup_static_dir();
|
||||
let data_dir = TempDir::new().unwrap();
|
||||
let mut cfg = (*test_config_with_smtp(
|
||||
static_dir.path().to_str().unwrap(),
|
||||
data_dir.path().to_str().unwrap(),
|
||||
))
|
||||
.clone();
|
||||
// A single slot and a short wait: the second send only succeeds when the
|
||||
// first one gave its permit back.
|
||||
cfg.max_concurrent_message_builds = 1;
|
||||
cfg.message_build_wait_secs = 1;
|
||||
let store = test_store();
|
||||
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
|
||||
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
||||
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
||||
let mock_imap = Arc::new(MockImapClient::new());
|
||||
let app = create_router(
|
||||
Arc::new(cfg),
|
||||
store,
|
||||
mock_imap.clone(),
|
||||
Arc::new(MockSmtpClient::new()),
|
||||
test_search_engine(data_dir.path().to_str().unwrap()),
|
||||
test_event_bus(),
|
||||
test_idle_manager(),
|
||||
);
|
||||
let auth = auth_headers(&browser_id, &account_id, &token);
|
||||
|
||||
for draft_id in ["slot-draft-1", "slot-draft-2"] {
|
||||
let upload = app
|
||||
.clone()
|
||||
.oneshot(multipart_upload_request(
|
||||
&format!("/api/drafts/{draft_id}/attachments"),
|
||||
"note.txt",
|
||||
b"hello attachment",
|
||||
&auth,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(upload.status(), StatusCode::OK);
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/messages/send")
|
||||
.header("x-requested-with", "XMLHttpRequest")
|
||||
.header("content-type", "application/json");
|
||||
for (name, value) in &auth {
|
||||
req = req.header(*name, value.as_str());
|
||||
}
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
req.body(Body::from(format!(
|
||||
r#"{{"to":["bob@example.com"],"subject":"With file","text_body":"see attached","draft_id":"{draft_id}"}}"#
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "send for {draft_id}");
|
||||
}
|
||||
assert_eq!(mock_imap.appended.lock().unwrap().len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! In-memory staging for "Undo Send".
|
||||
//!
|
||||
//! When `undo_send_window_secs > 0`, a fully built outgoing message is held
|
||||
//! in memory for that many seconds before it is actually dispatched to SMTP,
|
||||
//! so the sender can cancel it. Nothing here is persisted: a server restart
|
||||
//! during the window loses any message still staged, the same way a message
|
||||
//! already queued by any mail server can be lost if the process is killed
|
||||
//! mid-flight. The window is meant to be short (seconds, not minutes) for
|
||||
//! exactly this reason.
|
||||
//!
|
||||
//! State transitions are `Pending -> Cancelled` (a user clicks "Undo") or
|
||||
//! `Pending -> Dispatching -> Sent | Failed` (the window elapses). Both the
|
||||
//! cancel handler and the delayed dispatch task race to leave `Pending`, but
|
||||
//! only one of them can win because the transition happens under a single
|
||||
//! mutex: whichever reaches it first decides the outcome.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// How long a finished (sent/cancelled/failed) entry is kept around so a late
|
||||
/// status poll from the client still finds it.
|
||||
const RESULT_RETENTION_SECS: u64 = 120;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum PendingOutcome {
|
||||
Pending,
|
||||
Dispatching,
|
||||
Sent { message_id: String },
|
||||
Cancelled,
|
||||
Failed { error: String },
|
||||
}
|
||||
|
||||
struct PendingEntry {
|
||||
outcome: PendingOutcome,
|
||||
recorded_at: u64,
|
||||
}
|
||||
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UndoSendRegistry {
|
||||
entries: Mutex<HashMap<String, PendingEntry>>,
|
||||
}
|
||||
|
||||
impl UndoSendRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn sweep(entries: &mut HashMap<String, PendingEntry>) {
|
||||
let now = now_epoch();
|
||||
entries.retain(|_, entry| {
|
||||
matches!(
|
||||
entry.outcome,
|
||||
PendingOutcome::Pending | PendingOutcome::Dispatching
|
||||
) || now.saturating_sub(entry.recorded_at) < RESULT_RETENTION_SECS
|
||||
});
|
||||
}
|
||||
|
||||
/// Registers a new pending send and returns its id.
|
||||
pub fn stage(&self) -> String {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let mut entries = self.entries.lock().expect("undo-send registry lock");
|
||||
Self::sweep(&mut entries);
|
||||
entries.insert(
|
||||
id.clone(),
|
||||
PendingEntry {
|
||||
outcome: PendingOutcome::Pending,
|
||||
recorded_at: now_epoch(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
/// Cancels a still-pending send. Returns `true` if this call is the one
|
||||
/// that cancelled it; `false` if it was already dispatching/finished or
|
||||
/// the id is unknown/expired.
|
||||
pub fn cancel(&self, id: &str) -> bool {
|
||||
let mut entries = self.entries.lock().expect("undo-send registry lock");
|
||||
match entries.get_mut(id) {
|
||||
Some(entry) if entry.outcome == PendingOutcome::Pending => {
|
||||
entry.outcome = PendingOutcome::Cancelled;
|
||||
entry.recorded_at = now_epoch();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the delayed dispatch task once its wait is over, right
|
||||
/// before it would contact SMTP. Returns `true` if this call is the one
|
||||
/// that claimed the send (i.e. it was not cancelled in the meantime);
|
||||
/// the caller must then actually dispatch and record the result.
|
||||
pub fn begin_dispatch(&self, id: &str) -> bool {
|
||||
let mut entries = self.entries.lock().expect("undo-send registry lock");
|
||||
match entries.get_mut(id) {
|
||||
Some(entry) if entry.outcome == PendingOutcome::Pending => {
|
||||
entry.outcome = PendingOutcome::Dispatching;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_sent(&self, id: &str, message_id: String) {
|
||||
let mut entries = self.entries.lock().expect("undo-send registry lock");
|
||||
if let Some(entry) = entries.get_mut(id) {
|
||||
entry.outcome = PendingOutcome::Sent { message_id };
|
||||
entry.recorded_at = now_epoch();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failed(&self, id: &str, error: String) {
|
||||
let mut entries = self.entries.lock().expect("undo-send registry lock");
|
||||
if let Some(entry) = entries.get_mut(id) {
|
||||
entry.outcome = PendingOutcome::Failed { error };
|
||||
entry.recorded_at = now_epoch();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self, id: &str) -> Option<PendingOutcome> {
|
||||
let mut entries = self.entries.lock().expect("undo-send registry lock");
|
||||
Self::sweep(&mut entries);
|
||||
entries.get(id).map(|entry| entry.outcome.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pending_send_can_be_cancelled_exactly_once() {
|
||||
let registry = UndoSendRegistry::new();
|
||||
let id = registry.stage();
|
||||
assert_eq!(registry.status(&id), Some(PendingOutcome::Pending));
|
||||
assert!(registry.cancel(&id));
|
||||
assert_eq!(registry.status(&id), Some(PendingOutcome::Cancelled));
|
||||
// A second cancel is a no-op: the first one already won the race.
|
||||
assert!(!registry.cancel(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_cannot_start_once_cancelled() {
|
||||
let registry = UndoSendRegistry::new();
|
||||
let id = registry.stage();
|
||||
assert!(registry.cancel(&id));
|
||||
assert!(!registry.begin_dispatch(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_cannot_win_once_dispatch_has_begun() {
|
||||
let registry = UndoSendRegistry::new();
|
||||
let id = registry.stage();
|
||||
assert!(registry.begin_dispatch(&id));
|
||||
assert!(!registry.cancel(&id));
|
||||
registry.record_sent(&id, "msg-1".to_string());
|
||||
assert_eq!(
|
||||
registry.status(&id),
|
||||
Some(PendingOutcome::Sent {
|
||||
message_id: "msg-1".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dispatch_that_fails_is_recorded_as_failed() {
|
||||
let registry = UndoSendRegistry::new();
|
||||
let id = registry.stage();
|
||||
assert!(registry.begin_dispatch(&id));
|
||||
registry.record_failed(&id, "SMTP timed out".to_string());
|
||||
assert_eq!(
|
||||
registry.status(&id),
|
||||
Some(PendingOutcome::Failed {
|
||||
error: "SMTP timed out".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_id_has_no_status() {
|
||||
let registry = UndoSendRegistry::new();
|
||||
assert_eq!(registry.status("does-not-exist"), None);
|
||||
assert!(!registry.cancel("does-not-exist"));
|
||||
assert!(!registry.begin_dispatch("does-not-exist"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (state.selectionMode) {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
selectMessageRange(message);
|
||||
return;
|
||||
}
|
||||
if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message)));
|
||||
return setMessageSelected(message, !state.selectedMessages.has(messageKey(message)));
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
openMessageInNewTab(folder, message.uid);
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
openMessageInPopup(folder, message.uid);
|
||||
return;
|
||||
}
|
||||
setMessageSelectionAnchor(message);
|
||||
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_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;
|
||||
|
||||
@@ -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();
|
||||
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;
|
||||
|
||||
@@ -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); } },
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
Reference in new issue
Block a user