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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-01 21:36:04 +01:00
1 parent dacdf9c27d
commit a116890e20
34 files changed
+186 -83

No files matched your search

+9
View File
@@ -1,5 +1,14 @@
# RustBox changelog
### 0.2.9
- Made realtime new-mail notifications less aggressive: startup synchronization is silent, notifications are armed only after the application is open, and bulk move/copy/archive/junk/delete destinations suppress self-generated `NewMessages` toasts while the bulk summary toast remains.
- Added a success toast after a completed authenticated startup/login.
- Restored browser/system modifier shortcuts by limiting RustBox single-key shortcuts to unmodified keys; `Ctrl/Cmd+C`, `Ctrl/Cmd+V`, `Ctrl/Cmd+X`, `Ctrl/Cmd+A`, `Ctrl/Cmd+F`, `Ctrl/Cmd+R` and `Ctrl/Cmd+N` are no longer intercepted by mail actions.
- Changed the default messages-per-page setting from 50 to 300 and raised the supported folder-page limit to 300.
- No schema migration is required; V049 remains the next migration number.
- Bumped the application/source release to 0.2.9.
### 0.2.8
- Scoped default logging so RustBox can run at DEBUG/INFO without enabling verbose dependency logs; Tantivy merge/GC/file-watcher noise and its internal filesystem/source paths are hidden unless explicitly enabled through a custom `RUST_LOG` target filter. Development and deployment examples now use target-scoped filters.
Generated
+1 -1
View File
@@ -3506,7 +3506,7 @@ dependencies = [
[[package]]
name = "rustbox"
version = "0.2.8"
version = "0.2.9"
dependencies = [
"aes-gcm",
"async-imap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustbox"
version = "0.2.8"
version = "0.2.9"
edition = "2024"
rust-version = "1.99.1"
default-run = "rustbox"
+1 -1
View File
@@ -1,6 +1,6 @@
# RustBox attribution / notice
RustBox 0.2.8 is derived from the Oxi webmail project:
RustBox 0.2.9 is derived from the Oxi webmail project:
- Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+3 -3
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.8
# RustBox 0.2.9
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
@@ -34,7 +34,7 @@ rustbox/
`build.rs` combines the CSS modules from `src/web/css/manifest.txt` and JavaScript modules from `src/web/js/manifest.txt`. Cargo embeds the generated CSS/JS bundles, HTML and SVG sprite in the RustBox executable. There is no standalone source `src/web/app.js`; `src/web/` is the single source of truth and Cargo generates the embedded bundles in `OUT_DIR`.
## Storage and search in 0.2.8
## Storage and search in 0.2.9
RustBox separates three concerns: SQL metadata, blob storage and search. The SQL engine does **not** decide whether `data_dir` may be used. A single-node PostgreSQL/MySQL deployment may still use filesystem blobs and local Tantivy. In cluster mode, local node state is rejected unless the configured backend is explicitly shared.
@@ -235,7 +235,7 @@ Initialize Git after extracting the release archive:
```bash
git init
git add .
git commit -m "RustBox 0.2.8 release"
git commit -m "RustBox 0.2.9 release"
./scripts/setup-upstream.sh
```
+10 -19
View File
@@ -1,21 +1,12 @@
RustBox 0.2.8 release manifest
0.2.8 feature/configuration changes:
- fixes folder unread counts by treating IMAP STATUS UNSEEN as authoritative and never using SELECT/EXAMINE UNSEEN as a count
- falls back to UID SEARCH UNSEEN when STATUS is unavailable for the selected mailbox
- reconciles MESSAGES/UNSEEN even when HIGHESTMODSEQ is unchanged and rejects out-of-order stale counter writes with compare-and-set/retry
- updates individual message rows and folder counters optimistically/live for read/unread/flag events instead of rebuilding the message list, then reconciles them to authoritative IMAP state
- shows wide folder badges as total / unread, hides the unread suffix at zero, and switches to compact counters in narrow sidebars
- exposes IMAP header-cache and deep-index backlog through the activity indicator and tooltip
- keeps counter STATUS separate from the CONDSTORE cursor, falls back to full UID/FLAGS reconciliation when needed, and refuses to apply partial IMAP streams
- keeps security_headers_enabled opt-in, centralizes the RustBox-added browser security profile behind that switch, and applies the full document-only profile only to HTML/XHTML
- uses external same-origin JavaScript with `script-src 'self'`, disables inline script attributes, restricts stylesheets to `style-src 'self'`, and permits only dynamic style attributes via `style-src-attr 'unsafe-inline'`
- applies no-store, nosniff, same-origin CORP and an anti-framing `frame-ancestors 'none'`/X-Frame-Options policy to private API responses; the full document CSP plus Permissions-Policy/COOP remain HTML-only, and assets receive same-origin CORP where applicable
- leaves HSTS to the HTTPS/TLS-terminating deployment layer
- minimizes the public auth policy to rememberMeEnabled and moves optional feature availability behind authenticated /api/auth/session-policy
- adds/extends regression coverage and offline release checks for the changed behavior
- adds runtime migration V048 on SQLite/MySQL/PostgreSQL for monotonic folder-counter generations; V049 remains next
RustBox 0.2.9 release manifest
0.2.9 feature/configuration changes:
- suppresses startup new-mail toasts and arms realtime mail notifications only after authenticated startup is complete and the WebSocket has been open for a short grace period
- suppresses self-generated destination-folder NewMessages notifications during bulk move/copy/archive/junk/delete operations while retaining the bulk summary toast
- shows a success toast after authenticated startup/login
- preserves browser/system Ctrl/Cmd shortcuts by limiting RustBox single-key shortcuts to unmodified keys; compose remains available on C
- defaults mailbox pages/preferences to 300 messages and raises the supported per-page API/preference limit to 300
- requires no runtime schema migration; V049 remains next
Architecture:
- database_backend = sqlite | mysql | pgsql selects authoritative SQL only
- blob storage and search are independent from the SQL engine
@@ -51,14 +42,14 @@ Docker examples:
- docker/compose-mysql-nfs-opensearch.yml (MySQL + real NFS volume + OpenSearch)
Operations/docs:
- README.md documents 0.2.8 configuration
- README.md documents 0.2.9 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.8
- Cargo.toml version = 0.2.9
- 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
+11 -13
View File
@@ -1,23 +1,21 @@
RustBox 0.2.8 - final source validation
RustBox 0.2.9 - final source validation
0.2.8 scope:
- authoritative IMAP folder totals/unread counters from server state, including selected-mailbox fallback via UID SEARCH UNSEEN
- optimistic/targeted realtime read/unread/flag updates without full message-list rerenders
- responsive total/unread folder badges, authoritative FolderStatus updates, and separate counter/CONDSTORE cursor handling
- IMAP cache/deep-index progress surfaced through the activity indicator
- content-type-aware opt-in security headers with external-script `script-src 'self'` CSP and stylesheet-only `style-src 'self'`
- private API no-store/nosniff/same-origin-CORP plus anti-framing CSP/X-Frame-Options; full document CSP, Permissions-Policy and COOP remain HTML-only
- pre-login auth policy minimized to rememberMeEnabled; optional feature policy requires authentication
- runtime migration V048 adds monotonic folder-counter generations on SQLite/MySQL/PostgreSQL; V049 remains next
- version metadata updated to 0.2.8
0.2.9 scope:
- startup synchronization does not emit new-mail toasts; realtime mail notifications are armed after application startup
- bulk move/copy/archive/junk/delete actions suppress self-generated destination-folder new-mail notifications and retain one summary toast
- authenticated startup/login shows a success toast
- browser/system Ctrl/Cmd shortcuts are not intercepted by RustBox single-key mail shortcuts
- mailbox pages default to 300 messages and accept up to 300 per page
- no runtime schema migration is added; V049 remains next
- version metadata updated to 0.2.9
Completed in this environment:
- offline release validator: scripts/validate-release-static.py (279/279 checks passed)
- offline release validator: scripts/validate-release-static.py (340/340 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.8
- release metadata consistently identifies 0.2.9
- V048 migration files/catalog registration and SQLite upgrade execution are validated offline
+1 -1
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.8
# RustBox 0.2.9
host = "127.0.0.1"
port = 3001
environment = "production"
+10 -2
View File
@@ -1,6 +1,14 @@
# RustBox 0.2.8 - implementation status
# RustBox 0.2.9 - implementation status
This file tracks the `0.2.8` 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.9` 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.9 browser mail UX
- Initial synchronization no longer emits new-mail toasts. Realtime notifications are armed after the authenticated application is open.
- Bulk move/copy/archive/junk/delete operations suppress destination-folder `NewMessages` notifications generated by the operation itself and keep the single bulk summary toast.
- Browser/system modifier shortcuts are preserved; RustBox mail shortcuts use unmodified single keys.
- Mailbox pages default to 300 messages and the API/preferences accept values up to 300.
- No runtime schema migration is added; V049 remains next.
## 0.2.8 browser security headers
+1 -1
View File
@@ -26,7 +26,7 @@ RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config
Browser JavaScript source lives in functional modules under `src/web/js/`, ordered by `src/web/js/manifest.txt`. `build.rs` concatenates those modules into the embedded `rustbox-ui.js` asset during every Cargo build. The repository has no duplicate `ui/` snapshot; `src/web/` is the only browser UI source.
## Updating an existing checkout to 0.2.8
## Updating an existing checkout to 0.2.9
Do not overlay the release with `cp *` or another command that skips dotfiles. That can retain stale `.github`, `.env.example`, or `Cargo.lock` files.
+2 -2
View File
@@ -1,6 +1,6 @@
# RustBox 0.2.8 storage/search operations
# RustBox 0.2.9 storage/search operations
RustBox 0.2.8 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed.
RustBox 0.2.9 treats SQL, blob storage and search as independent capabilities. The database engine does not decide whether `data_dir` is allowed.
## Deployment matrix
+2 -2
View File
@@ -1,6 +1,6 @@
# Database schemas and migrations
RustBox 0.2.8 keeps SQL, blob storage and search as independent concerns:
RustBox 0.2.9 keeps SQL, blob storage and search as independent concerns:
```text
database_backend = sqlite | mysql | pgsql
@@ -37,7 +37,7 @@ Account data is isolated inside the selected physical database with a determinis
Every schema-changing release must add the same logical migration for all supported SQL engines and register it in `src/db/pool.rs`. Do not update only `schema.sql`: fresh installs use the baseline while existing installations use the numbered migration path.
The next migration number after RustBox 0.2.8 is **V049**.
RustBox 0.2.9 does not add a runtime schema migration; **V049** remains the next migration number.
Redis and Redis Sentinel do not use SQL migrations.
+1 -1
View File
@@ -281,7 +281,7 @@ CREATE TABLE {{p}}known_addresses (
CREATE TABLE {{p}}mail_preferences (
id BIGINT PRIMARY KEY,
messages_per_page BIGINT NOT NULL DEFAULT 50,
messages_per_page BIGINT NOT NULL DEFAULT 300,
mark_read_delay_ms BIGINT NOT NULL DEFAULT 0,
remote_images TEXT NOT NULL DEFAULT ('ask'),
preferred_body TEXT NOT NULL DEFAULT ('auto'),
+1 -1
View File
@@ -282,7 +282,7 @@ CREATE TABLE {{p}}known_addresses (
CREATE TABLE {{p}}mail_preferences (
id BIGINT PRIMARY KEY,
messages_per_page BIGINT NOT NULL DEFAULT 50,
messages_per_page BIGINT NOT NULL DEFAULT 300,
mark_read_delay_ms BIGINT NOT NULL DEFAULT 0,
remote_images TEXT NOT NULL DEFAULT 'ask',
preferred_body TEXT NOT NULL DEFAULT 'auto',
+1 -1
View File
@@ -206,7 +206,7 @@ CREATE TABLE {{p}}known_addresses (
CREATE TABLE {{p}}mail_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
messages_per_page INTEGER NOT NULL DEFAULT 50,
messages_per_page INTEGER NOT NULL DEFAULT 300,
mark_read_delay_ms INTEGER NOT NULL DEFAULT 0,
remote_images TEXT NOT NULL DEFAULT 'ask',
preferred_body TEXT NOT NULL DEFAULT 'auto',
+1 -1
View File
@@ -1,4 +1,4 @@
# RustBox 0.2.8 - packaged production configuration (DEB/RPM)
# RustBox 0.2.9 - packaged production configuration (DEB/RPM)
#
# This file contains all supported RustBox configuration options.
# Commented options are optional and do not change the packaged defaults.
+18 -7
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Static/offline release checks for RustBox 0.2.8.
"""Static/offline release checks for RustBox 0.2.9.
These checks intentionally avoid pretending to replace `cargo test`: they validate
configuration, SQL migrations, Docker examples, query-catalog parity and release
@@ -49,14 +49,14 @@ def extract_section(text: str, begin: str, end: str) -> str:
# Release/toolchain metadata.
cargo = tomllib.loads(read("Cargo.toml"))
require(cargo["package"]["version"] == "0.2.8", "Cargo package version is 0.2.8")
require(cargo["package"]["version"] == "0.2.9", "Cargo package version is 0.2.9")
require(cargo["package"]["rust-version"] == "1.99.1", "Cargo rust-version is 1.99.1")
toolchain = tomllib.loads(read("rust-toolchain.toml"))
require(toolchain["toolchain"]["channel"] == "1.99.1", "rust-toolchain pins Rust 1.99.1")
require((ROOT / "Cargo.lock").is_file(), "Cargo.lock is retained for reproducible application builds")
lock = tomllib.loads(read("Cargo.lock"))
lock_rustbox = next((pkg for pkg in lock.get("package", []) if pkg.get("name") == "rustbox"), {})
require(lock_rustbox.get("version") == "0.2.8", "Cargo.lock RustBox package version is 0.2.8")
require(lock_rustbox.get("version") == "0.2.9", "Cargo.lock RustBox package version is 0.2.9")
require("cargo generate-lockfile" in read("scripts/build-release.sh"), "release script generates Cargo.lock when missing")
release_script = read("scripts/build-release.sh")
release_script_flat = " ".join(release_script.replace("\\", " ").split())
@@ -69,10 +69,21 @@ require("make_zip.py" in package_script and "sha256sum" in package_script, "rele
require("cargo generate-lockfile" in read(".github/workflows/ci.yml"), "CI regenerates Cargo.lock before --locked checks")
require("validate-release-static.py" in read("scripts/build-release.sh"), "release script runs offline/static release gate")
require("validate-release-static.py" in read(".github/workflows/ci.yml"), "CI runs offline/static release gate")
require(read("README.md").startswith("# RustBox 0.2.8"), "README identifies RustBox 0.2.8")
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.8"), "release manifest identifies RustBox 0.2.8")
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.8"), "release validation identifies RustBox 0.2.8")
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.8"), "implementation status identifies RustBox 0.2.8")
require(read("README.md").startswith("# RustBox 0.2.9"), "README identifies RustBox 0.2.9")
require(read("RELEASE-MANIFEST.txt").startswith("RustBox 0.2.9"), "release manifest identifies RustBox 0.2.9")
require(read("RELEASE-VALIDATION.txt").startswith("RustBox 0.2.9"), "release validation identifies RustBox 0.2.9")
require(read("docs/IMPLEMENTATION_STATUS.md").startswith("# RustBox 0.2.9"), "implementation status identifies RustBox 0.2.9")
bootstrap_js = read("src/web/js/00-bootstrap.js")
bindings_js = read("src/web/js/95-bindings-boot.js")
realtime_js = read("src/web/js/80-settings-realtime.js")
mail_actions_js = read("src/web/js/60-mail-actions-search.js")
require("perPage: 300" in bootstrap_js and "messages_per_page: 300" in bootstrap_js, "browser mailbox default is 300 messages")
require(re.search(r"default_per_page\(\)\s*->\s*u32\s*\{\s*300\s*\}", read("src/routes/messages/types.rs")) is not None, "folder message API default page size is 300")
require('1..=300' in read("src/routes/messages/mod.rs"), "folder message API accepts up to 300 messages per page")
require('20..=300' in read("src/db/mail_preferences.rs"), "mailbox preference accepts 300 messages per page")
require('const appShortcut = keyboardIdle && !event.ctrlKey && !event.metaKey && !event.altKey;' in bindings_js, "single-key shortcuts preserve Ctrl/Cmd/Alt browser shortcuts")
require('realtimeNotificationsReady' in realtime_js and 'suppressRealtimeNotificationsForFolder' in realtime_js, "realtime new-mail notification gating is present")
require('suppressRealtimeNotificationsForFolder(notificationDestination)' in mail_actions_js, "bulk destination notifications are suppressed")
require("V048" in read("RELEASE-MANIFEST.txt") and "V049" in read("RELEASE-MANIFEST.txt"), "release manifest documents V048 and reserves V049")
require("V048" in read("migrations/README.md") and "V049" in read("migrations/README.md"), "migration guide documents V048 and reserves V049")
require((ROOT / "docs/STORAGE_SEARCH_OPERATIONS.md").is_file(), "storage/search operations guide exists")
+3 -3
View File
@@ -57,9 +57,9 @@ pub fn update_preferences(
.map_err(|error| format!("Failed to initialize mail preferences: {error}"))?;
if let Some(value) = data.messages_per_page
&& !(20..=200).contains(&value)
&& !(20..=300).contains(&value)
{
return Err("messages_per_page must be between 20 and 200".to_string());
return Err("messages_per_page must be between 20 and 300".to_string());
}
if let Some(value) = data.mark_read_delay_ms
&& value > 30_000
@@ -151,7 +151,7 @@ mod tests {
fn defaults_and_update_work() {
let conn = open_test_db();
let defaults = get_preferences(&conn).unwrap();
assert_eq!(defaults.messages_per_page, 50);
assert_eq!(defaults.messages_per_page, 300);
assert_eq!(defaults.remote_images, "ask");
let updated = update_preferences(
+3 -3
View File
@@ -1923,19 +1923,19 @@ fn tenant_seed_sql(backend: DatabaseBackend, prefix: &str) -> String {
DatabaseBackend::Sqlite => format!(
"INSERT OR IGNORE INTO {prefix}user_meta (key, value) VALUES ('schema_version', '1');\n\
INSERT OR IGNORE INTO {prefix}notification_preferences (id) VALUES (1);\n\
INSERT OR IGNORE INTO {prefix}mail_preferences (id) VALUES (1);\n\
INSERT OR IGNORE INTO {prefix}mail_preferences (id, messages_per_page) VALUES (1, 300);\n\
INSERT OR IGNORE INTO {prefix}pgp_preferences (id) VALUES (1);"
),
DatabaseBackend::Mysql => format!(
"INSERT IGNORE INTO {prefix}user_meta (`key`, value) VALUES ('schema_version', '1');\n\
INSERT IGNORE INTO {prefix}notification_preferences (id) VALUES (1);\n\
INSERT IGNORE INTO {prefix}mail_preferences (id) VALUES (1);\n\
INSERT IGNORE INTO {prefix}mail_preferences (id, messages_per_page) VALUES (1, 300);\n\
INSERT IGNORE INTO {prefix}pgp_preferences (id) VALUES (1);"
),
DatabaseBackend::Pgsql => format!(
"INSERT INTO {prefix}user_meta (key, value) VALUES ('schema_version', '1') ON CONFLICT (key) DO NOTHING;\n\
INSERT INTO {prefix}notification_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n\
INSERT INTO {prefix}mail_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n\
INSERT INTO {prefix}mail_preferences (id, messages_per_page) VALUES (1, 300) ON CONFLICT (id) DO NOTHING;\n\
INSERT INTO {prefix}pgp_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING;"
),
}
+2 -2
View File
@@ -14,7 +14,7 @@ pub fn reset_all(conn: &mut Connection) -> Result<(), String> {
"DELETE FROM calendar_settings",
"DELETE FROM pgp_preferences",
"INSERT INTO display_preferences (id) VALUES (1)",
"INSERT INTO mail_preferences (id) VALUES (1)",
"INSERT INTO mail_preferences (id, messages_per_page) VALUES (1, 300)",
"INSERT INTO notification_preferences (id) VALUES (1)",
"INSERT INTO calendar_settings (id) VALUES (1)",
"INSERT INTO pgp_preferences (id) VALUES (1)",
@@ -85,7 +85,7 @@ mod tests {
mail_preferences::get_preferences(&conn)
.unwrap()
.messages_per_page,
50
300
);
assert_eq!(
folder_preferences::get_preferences(&conn, "INBOX")
+2 -1
View File
@@ -270,7 +270,8 @@ pub mod db_identities {
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT IGNORE INTO mail_preferences (id) VALUES (1)";
pub const Q_002: &str =
"INSERT IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?, mark_read_delay_ms = ?, remote_images = ?, preferred_body = ?, thread_mode = ?, collapse_quotes = ?, show_snippets = ?, default_sort = ?, flagged_on_top = ?, sync_imap_keywords = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1";
}
+1 -2
View File
@@ -270,8 +270,7 @@ pub mod db_identities {
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str =
"INSERT INTO mail_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING";
pub const Q_002: &str = "INSERT INTO mail_preferences (id, messages_per_page) VALUES (1, 300) ON CONFLICT (id) DO NOTHING";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, updated_at = CURRENT_TIMESTAMP::text WHERE id = 1";
}
+2 -1
View File
@@ -269,7 +269,8 @@ pub mod db_identities {
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO mail_preferences (id) VALUES (1)";
pub const Q_002: &str =
"INSERT OR IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, updated_at = datetime('now') WHERE id = 1";
}
+2 -2
View File
@@ -63,9 +63,9 @@ pub async fn list_messages(
Path(folder): Path<String>,
Query(query): Query<ListMessagesQuery>,
) -> Result<Response, AppError> {
if !(1..=200).contains(&query.per_page) {
if !(1..=300).contains(&query.per_page) {
return Err(AppError::BadRequest(
"per_page must be between 1 and 200".to_string(),
"per_page must be between 1 and 300".to_string(),
));
}
if !matches!(
+1 -1
View File
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
pub use crate::email_theme::EmailTheme;
pub fn default_per_page() -> u32 {
50
300
}
pub fn default_sort() -> String {
+2 -1
View File
@@ -55,5 +55,6 @@
"message_details": "Message details",
"move_copy": "Move / copy",
"move_to": "Move to…",
"copy_to": "Copy to…"
"copy_to": "Copy to…",
"login_success": "Successfully logged in"
}
+2 -1
View File
@@ -640,5 +640,6 @@
"message_details": "Szczegóły",
"move_copy": "Przenieś / kopiuj",
"move_to": "Przenieś do…",
"copy_to": "Kopiuj do…"
"copy_to": "Kopiuj do…",
"login_success": "Pomyślnie zalogowano"
}
+2 -1
View File
@@ -784,6 +784,7 @@
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
</select></label>
<label><span>Mark as read</span><select id="mail-pref-read-delay">
<option value="0">Immediately</option>
@@ -1460,7 +1461,7 @@
<dl>
<dt><kbd>/</kbd></dt>
<dd>Focus search</dd>
<dt><kbd>C</kbd> / <kbd>Ctrl</kbd> + <kbd>N</kbd></dt>
<dt><kbd>C</kbd></dt>
<dd>New message</dd>
<dt><kbd>V</kbd></dt>
<dd>Toggle selection mode</dd>
+7 -2
View File
@@ -15,7 +15,7 @@
folder: "INBOX",
collapsedFolders: new Set(),
page: 0,
perPage: 50,
perPage: 300,
total: 0,
messages: [],
selected: null,
@@ -45,7 +45,7 @@
savedSearches: [],
responseTemplates: [],
notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" },
mailPrefs: { messages_per_page: 50, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false },
mailPrefs: { messages_per_page: 300, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false },
currentFolderFlaggedOnTop: false,
markReadTimer: null,
ws: null,
@@ -61,7 +61,12 @@
realtimeSyncPromise: null,
realtimeSeenEvents: new Map(),
realtimeNotifiedMessages: new Map(),
realtimeNotificationSuppressions: new Map(),
realtimeNotificationsReady: false,
realtimeNotificationsArmRequested: false,
realtimeNotificationsReadyTimer: null,
realtimeWatchedFolder: null,
startupToastsMuted: true,
indexingActive: false,
indexingPending: 0,
imapSyncActive: false,
+1
View File
@@ -171,6 +171,7 @@ function uploadRequest(path, formData, onProgress = null, signal = null) {
}
function toast(message, kind = "info", duration = null, action = null) {
if (state.startupToastsMuted) return null;
const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info";
const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800;
const node = el("div", `toast toast-${normalized}`);
+16 -2
View File
@@ -315,7 +315,7 @@ async function loadMailPreferences() {
try {
const prefs = await request("/settings/mailbox", { method: "GET" });
state.mailPrefs = { ...state.mailPrefs, ...prefs };
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
state.perPage = Number(state.mailPrefs.messages_per_page || 300);
const values = {
"#mail-pref-per-page": String(state.perPage),
"#mail-pref-read-delay": String(state.mailPrefs.mark_read_delay_ms || 0),
@@ -353,7 +353,7 @@ async function saveMailPreferences() {
};
try {
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify(payload) });
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
state.perPage = Number(state.mailPrefs.messages_per_page || 300);
toast("Mailbox settings saved");
if (state.currentDetail) renderMessageDetail(state.currentDetail);
await loadMessages(true);
@@ -458,6 +458,17 @@ function showApp() {
$("#app-view").classList.remove("hidden");
}
function finishAuthenticatedStartup() {
state.startupToastsMuted = false;
armRealtimeNotifications();
toast(tr("login_success", "Successfully logged in"), "success");
}
function prepareAuthenticatedStartup() {
state.startupToastsMuted = true;
resetRealtimeNotificationGate();
}
function settingsTabAvailable(name) {
if (name === "openpgp") return state.authPolicy.pgpEnabled !== false;
if (name === "sieve") return state.authPolicy.sieveEnabled !== false;
@@ -697,6 +708,7 @@ async function login(event) {
const errorBox = $("#login-error");
errorBox.classList.add("hidden");
setBusy(submit, true);
prepareAuthenticatedStartup();
try {
const data = await request("/auth/login", {
method: "POST",
@@ -723,6 +735,7 @@ async function login(event) {
await loadTrustedSenders();
startCalendarReminderWatcher();
connectRealtime();
finishAuthenticatedStartup();
} catch (error) {
errorBox.textContent = error.message;
errorBox.classList.remove("hidden");
@@ -733,6 +746,7 @@ async function login(event) {
async function logout() {
disconnectRealtime();
prepareAuthenticatedStartup();
if (state.calendarReminderTimer) { clearInterval(state.calendarReminderTimer); state.calendarReminderTimer = null; }
try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) { }
state.accountId = null;
+10
View File
@@ -235,6 +235,14 @@ async function performBulk(action, targetFolder = null) {
}
const permanentDelete = action === "delete" && items.every((message) => (message.folder || state.folder) === detectSpecialFolder("trash"));
if (permanentDelete && !await appConfirm(`Permanently delete ${items.length} selected messages?`, { title: "Delete selected messages", confirmLabel: "Delete permanently", danger: true })) return;
let notificationDestination = null;
if (action === "move" || action === "copy") notificationDestination = targetFolder;
else if (action === "archive") notificationDestination = detectSpecialFolder("archive");
else if (action === "junk") notificationDestination = bulkJunkToInbox ? inboxFolder() : detectSpecialFolder("junk");
else if (action === "delete" && !permanentDelete) notificationDestination = detectSpecialFolder("trash");
const releaseRealtimeNotificationSuppression = notificationDestination
? suppressRealtimeNotificationsForFolder(notificationDestination)
: null;
let completed = 0;
const undoMoves = [];
let movedOperations = 0;
@@ -299,6 +307,8 @@ async function performBulk(action, targetFolder = null) {
toast(`${completed}/${items.length} completed: ${error.message}`, "error");
state.searchAllResults = null;
await refreshCurrentMessageView();
} finally {
releaseRealtimeNotificationSuppression?.();
}
}
+51 -1
View File
@@ -219,6 +219,54 @@ function newMailNotificationKey(data, accountId) {
return `${accountId || ""}|${data?.folder || "INBOX"}|${data?.latest_sender || ""}|${data?.latest_subject || ""}|${data?.count || 1}`;
}
function realtimeNotificationSuppressionKey(folder, accountId = state.accountId) {
return `${accountId || ""}|${folder || "INBOX"}`;
}
function suppressRealtimeNotificationsForFolder(folder, accountId = state.accountId) {
if (!folder) return () => { };
const key = realtimeNotificationSuppressionKey(folder, accountId);
state.realtimeNotificationSuppressions.set(key, (state.realtimeNotificationSuppressions.get(key) || 0) + 1);
let released = false;
return () => {
if (released) return;
released = true;
setTimeout(() => {
const count = state.realtimeNotificationSuppressions.get(key) || 0;
if (count <= 1) state.realtimeNotificationSuppressions.delete(key);
else state.realtimeNotificationSuppressions.set(key, count - 1);
}, 2500);
};
}
function realtimeNewMailNotificationsSuppressed(data, accountId) {
if (!state.realtimeNotificationsReady) return true;
return (state.realtimeNotificationSuppressions.get(realtimeNotificationSuppressionKey(data?.folder, accountId)) || 0) > 0;
}
function scheduleRealtimeNotificationsReady() {
if (!state.realtimeNotificationsArmRequested || state.realtimeNotificationsReady) return;
clearTimeout(state.realtimeNotificationsReadyTimer);
state.realtimeNotificationsReadyTimer = setTimeout(() => {
state.realtimeNotificationsReadyTimer = null;
state.realtimeNotificationsReady = true;
}, 3000);
}
function armRealtimeNotifications() {
state.realtimeNotificationsArmRequested = true;
if (state.ws?.readyState === WebSocket.OPEN) scheduleRealtimeNotificationsReady();
}
function resetRealtimeNotificationGate() {
clearTimeout(state.realtimeNotificationsReadyTimer);
state.realtimeNotificationsReadyTimer = null;
state.realtimeNotificationsReady = false;
state.realtimeNotificationsArmRequested = false;
state.realtimeNotificationSuppressions.clear();
state.realtimeNotifiedMessages.clear();
}
function isDuplicateNewMailNotification(data, accountId) {
const now = Date.now();
for (const [key, seenAt] of state.realtimeNotifiedMessages) {
@@ -455,6 +503,7 @@ function connectRealtime() {
state.realtimeWatchedFolder = null;
startRealtimeWatchdog(socket);
syncRealtimeWatchedFolder(true);
scheduleRealtimeNotificationsReady();
if (reconnect) void resyncRealtimeState();
};
socket.onmessage = (message) => {
@@ -470,7 +519,8 @@ function connectRealtime() {
const event = payload.event || {};
if (event.type === "NewMessages") {
const data = event.data || {};
if (!isDuplicateNewMailNotification(data, payload.accountId)) {
const duplicate = isDuplicateNewMailNotification(data, payload.accountId);
if (!duplicate && !realtimeNewMailNotificationsSuppressed(data, payload.accountId)) {
toastNewMail(data, payload.accountId);
notifyNewMail(data, payload.accountId);
}
+5 -3
View File
@@ -497,19 +497,19 @@ function bindEvents() {
if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) {
event.preventDefault(); $("#search-input").focus();
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "n") { event.preventDefault(); openCompose("new"); }
if (event.key === "?" && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable) {
event.preventDefault();
if (!document.querySelector("dialog[open]")) $("#shortcuts-dialog").showModal();
}
const dialogOpen = Boolean(document.querySelector("dialog[open]"));
const keyboardIdle = !dialogOpen && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable;
if (keyboardIdle) {
const appShortcut = keyboardIdle && !event.ctrlKey && !event.metaKey && !event.altKey;
if (appShortcut) {
const globalKey = event.key.toLowerCase();
if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; }
if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; }
}
if (keyboardIdle && state.currentDetail) {
if (appShortcut && state.currentDetail) {
const key = event.key.toLowerCase();
if (key === "x") {
const current = state.messages.find((message) => message.uid === state.currentDetail.uid && (message.folder || state.folder) === state.currentDetail.folder);
@@ -559,11 +559,13 @@ async function boot() {
await loadAuthPolicy();
const authenticated = await loadAccounts();
if (authenticated) {
prepareAuthenticatedStartup();
await loadSessionPolicy(); await loadPreferences(); await loadMailPreferences(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp(); else setOpenPgpAvailability(false); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime();
const params = new URLSearchParams(location.search);
const folder = params.get("mail_folder");
const uid = Number(params.get("mail_uid"));
if (folder && Number.isFinite(uid) && uid > 0) await openMessageTarget(state.accountId, folder, uid, params.get("mail_fullscreen") === "1");
finishAuthenticatedStartup();
} else { await loadTranslations(state.prefs.language); }
}