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