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

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

No files matched your search

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