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

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 17:22:17 +01:00
1 parent 0421994fe6
commit 425ea7449b
138 files changed
+5620 -927

No files matched your search

+24
View File
@@ -1,5 +1,29 @@
# RustBox changelog
### 0.1.0
- Cleaned the final release gate for Rust 1.99 Clippy with `-D warnings`: refactored persistent session creation into a typed input struct and fixed all reported collapsible conditionals, redundant closures, needless borrows/question marks, test layout and collection checks without disabling Clippy globally.
- Fixed fresh user-database provisioning so only SQLite user migrations `V001`-`V046` are applied; session migration `V047` is now exclusively owned by the configured session-store backend and MySQL SQL can no longer be executed against a user SQLite database.
- Removed the obsolete Refinery migration path/dependency and its `refinery_schema_history` bridge; RustBox now has one canonical user-database migration runner.
- Removed unused test-only session removal helpers that emitted `dead_code` warnings during `cargo test`.
- Strengthened `scripts/build-release.sh` to require formatting, locked tests, Clippy with `-D warnings`, and a locked release build before producing a release binary.
- Removed production-build warning noise from the multi-dialect query catalogs without disabling warnings crate-wide; test-only session/SMTP helpers are now compiled only under `cfg(test)`.
- Fixed cached-message row decoding after the query-catalog refactor: message list queries now project the exact `CachedMessage` column order (`flags`, `size`, `has_attachments`, `snippet`, `reaction`) across SQLite/MySQL/PostgreSQL.
- Fixed Rust `macro_rules!` query hygiene errors by passing dynamic SQL fragments and parameter indexes explicitly into SQLite query macros instead of capturing caller-local variables.
- Fixed the `libsqlite3-sys` Cargo links conflict by using `rusqlite` for SQLite session persistence and limiting `sqlx` to MySQL/PostgreSQL drivers.
- Refactored all executable Rust SQL into a centralized dialect tree at `src/queries/{sqlite,mysql,pgsql}/queries.rs`; application modules no longer embed SQL statements.
- Added persistent sessions that survive process restarts; SQLite under `data_dir/rustbox.sqlite` is the default with no extra configuration.
- Added MySQL and PostgreSQL session-database backends plus independently configurable Redis sessions with direct or Sentinel master discovery.
- Added cluster mode validation and shared-session reads so authenticated traffic can move between nodes when a shared SQL/Redis backend is used.
- Encrypts persisted IMAP/SMTP session credentials with AES-256-GCM; single-node SQLite auto-creates `data_dir/session.key`, while shared/cluster stores require an explicit common key.
- Centralized the complete SQL query catalog by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`; session queries use the same tree. Flattened migrations directly under `migrations/{sqlite,mysql,pgsql}/`; SQLite user migrations remain `V001`-`V046` and sessions use `V047__sessions.sql` across SQL dialects.
- Bumped the application/source release to `0.1.0`.
### 0.0.7
- Added `allow-downloads` to the sandboxed HTML message reader so user-initiated downloads are no longer blocked by Chromium sandbox policy.
- Added GitHub-, GitLab-, Discord-, Slack-, Reddit- and Notion-inspired predefined interface styles with light/dark palettes.
- Added a release contract that verifies every predefined interface style is visible in the application selector, has a JavaScript profile and CSS palette, and is accepted by backend/config validation.
- Bumped the application/source release to `0.0.7`.
### 0.0.6
- Allowed links in rendered HTML messages to open in a new tab from the sandboxed reader while keeping the opened page outside the message sandbox.
- Added eight predefined color presets: Emerald, Lime, Sky, Navy, Plum, Magenta, Copper, and Sand.
Generated
+507 -75
View File
@@ -198,6 +198,15 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [
"num-traits",
]
[[package]]
name = "atomic"
version = "0.6.1"
@@ -342,6 +351,9 @@ name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
dependencies = [
"serde_core",
]
[[package]]
name = "bitpacking"
@@ -526,6 +538,20 @@ dependencies = [
"digest",
]
[[package]]
name = "combine"
version = "4.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
dependencies = [
"bytes",
"futures-core",
"memchr",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]
name = "compression-codecs"
version = "0.4.37"
@@ -602,6 +628,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc24"
version = "0.1.6"
@@ -645,6 +686,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -904,6 +954,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -983,6 +1039,9 @@ name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
dependencies = [
"serde",
]
[[package]]
name = "elliptic-curve"
@@ -1050,6 +1109,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "etcetera"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
dependencies = [
"cfg-if",
"home",
"windows-sys 0.48.0",
]
[[package]]
name = "event-listener"
version = "2.5.3"
@@ -1259,6 +1329,17 @@ dependencies = [
"futures-util",
]
[[package]]
name = "futures-intrusive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
dependencies = [
"futures-core",
"lock_api",
"parking_lot",
]
[[package]]
name = "futures-io"
version = "0.3.32"
@@ -1511,6 +1592,15 @@ dependencies = [
"digest",
]
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "htmlescape"
version = "0.3.1"
@@ -1922,6 +2012,18 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"bitflags",
"libc",
"plain",
"redox_syscall 0.9.5",
]
[[package]]
name = "libsqlite3-sys"
version = "0.32.0"
@@ -2153,6 +2255,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@@ -2390,7 +2502,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -2572,6 +2684,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polyval"
version = "0.6.2"
@@ -2805,6 +2923,27 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "redis"
version = "0.32.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
dependencies = [
"bytes",
"cfg-if",
"combine",
"futures-util",
"itoa",
"num-bigint",
"percent-encoding",
"pin-project-lite",
"ryu",
"socket2",
"tokio",
"tokio-util",
"url",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2815,46 +2954,12 @@ dependencies = [
]
[[package]]
name = "refinery"
version = "0.9.0"
name = "redox_syscall"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c427f2572afe5c6cbfa2b1bf40071c89bf1a8539e958ea582842f6f38dcfae"
checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
dependencies = [
"refinery-core",
"refinery-macros",
]
[[package]]
name = "refinery-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702655abfc67f93a6f735e9fa4ace7d2e580633f8961f28acbfd7583ddce936c"
dependencies = [
"async-trait",
"cfg-if",
"log",
"regex",
"rusqlite",
"serde",
"siphasher",
"thiserror 2.0.18",
"time",
"toml",
"url",
"walkdir",
]
[[package]]
name = "refinery-macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5145756cdf293b5089dc6b4f103f1a1229cc55d67082c866f8c8289531c4b983"
dependencies = [
"proc-macro2",
"quote",
"refinery-core",
"regex",
"syn 2.0.117",
"bitflags",
]
[[package]]
@@ -2902,6 +3007,20 @@ dependencies = [
"subtle",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "ripemd"
version = "0.1.3"
@@ -2957,8 +3076,9 @@ dependencies = [
[[package]]
name = "rustbox"
version = "0.0.6"
version = "0.1.0"
dependencies = [
"aes-gcm",
"async-imap",
"async-native-tls",
"async-trait",
@@ -2977,7 +3097,7 @@ dependencies = [
"pgp",
"rand 0.8.8",
"rand 0.9.2",
"refinery",
"redis",
"regex",
"rusqlite",
"serde",
@@ -2985,6 +3105,7 @@ dependencies = [
"sha1",
"sha2",
"smallvec",
"sqlx",
"tantivy",
"tempfile",
"tokio",
@@ -3038,6 +3159,40 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3050,15 +3205,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
@@ -3303,12 +3449,6 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "sketches-ddsketch"
version = "0.2.2"
@@ -3329,6 +3469,9 @@ name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
[[package]]
name = "snafu"
@@ -3386,6 +3529,170 @@ dependencies = [
"der",
]
[[package]]
name = "sqlx"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
"sqlx-mysql",
"sqlx-postgres",
]
[[package]]
name = "sqlx-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"bytes",
"crc",
"crossbeam-queue",
"either",
"event-listener 5.4.1",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"indexmap",
"log",
"memchr",
"once_cell",
"percent-encoding",
"rustls",
"serde",
"serde_json",
"sha2",
"smallvec",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tracing",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "sqlx-macros"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.117",
]
[[package]]
name = "sqlx-macros-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
"heck",
"hex",
"once_cell",
"proc-macro2",
"quote",
"serde",
"serde_json",
"sha2",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
"syn 2.0.117",
"tokio",
"url",
]
[[package]]
name = "sqlx-mysql"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"bitflags",
"byteorder",
"bytes",
"crc",
"digest",
"dotenvy",
"either",
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"generic-array",
"hex",
"hkdf",
"hmac",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.8",
"rsa",
"serde",
"sha1",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.18",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-postgres"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"bitflags",
"byteorder",
"crc",
"dotenvy",
"etcetera",
"futures-channel",
"futures-core",
"futures-util",
"hex",
"hkdf",
"hmac",
"home",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"rand 0.8.8",
"serde",
"serde_json",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.18",
"tracing",
"whoami",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3417,6 +3724,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3718,6 +4036,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.49.0"
@@ -4048,12 +4372,33 @@ dependencies = [
"version_check",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -4076,6 +4421,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4136,16 +4487,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -4179,6 +4520,12 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.113"
@@ -4278,6 +4625,34 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.11",
]
[[package]]
name = "webpki-roots"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -4294,15 +4669,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@@ -4315,6 +4681,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -4351,6 +4726,21 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -4384,6 +4774,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -4396,6 +4792,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -4408,6 +4810,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -4432,6 +4840,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -4444,6 +4858,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -4456,6 +4876,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -4468,6 +4894,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "rustbox"
version = "0.0.6"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
default-run = "rustbox"
@@ -25,7 +25,6 @@ rand = "0.9"
base64 = "0.22"
sha2 = "0.10"
rusqlite = { version = "0.34", features = ["bundled"] }
refinery = { version = "0.9", features = ["rusqlite"] }
axum-extra = { version = "0.10", features = ["cookie"] }
async-trait = "0.1.92"
mail-parser = "0.11.2"
@@ -41,6 +40,10 @@ rand08 = { package = "rand", version = "0.8.6" }
smallvec = "1"
sha1 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "mysql", "postgres", "macros"] }
redis = { version = "0.32", default-features = false, features = ["tokio-comp"] }
aes-gcm = "0.10"
[dev-dependencies]
http-body-util = "0.1"
tempfile = "3"
+1 -1
View File
@@ -1,6 +1,6 @@
# RustBox attribution / notice
RustBox 0.0.6 is derived from the Oxi webmail project:
RustBox 0.1.0 is derived from the Oxi webmail project:
- Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+37 -5
View File
@@ -1,4 +1,4 @@
# RustBox 0.0.6
# RustBox 0.1.0
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
@@ -9,7 +9,6 @@ The repository is one Cargo application. The RustBox browser interface lives und
```text
rustbox/
├── Cargo.toml
├── Cargo.lock
├── build.rs
├── src/
│ ├── main.rs
@@ -43,7 +42,7 @@ Edit `config.dev.toml` before the first run:
```toml
default_theme = "system" # system | dark | light
default_interface_skin = "rustbox" # rustbox | gmail | proton | zoho | outlook | fastmail | apple-mail | yahoo | hey | nord | ...
default_interface_skin = "rustbox" # rustbox | gmail | proton | zoho | outlook | fastmail | apple-mail | yahoo | hey | nord | github | gitlab | discord | slack | reddit | notion | ...
# Mail hosts may be omitted: IMAP defaults to localhost:993 and SMTP to localhost:587.
# imap_host = "localhost"
# smtp_host = "localhost"
@@ -71,6 +70,39 @@ 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.
## Persistent sessions and cluster traffic
RustBox 0.1.0 persists login sessions by default in SQLite at `data_dir/rustbox.sqlite`. The session contains encrypted IMAP/SMTP credentials so it can survive a process restart; the encryption key is stored as `data_dir/session.key` with restrictive permissions for the default single-node SQLite setup.
RustBox uses `rusqlite` for SQLite and `sqlx` only for MySQL/PostgreSQL, avoiding duplicate native SQLite linkage in the Cargo dependency graph.
The session database backend can be `sqlite`, `mysql` or `pgsql`. For multi-node deployments use MySQL/PostgreSQL or Redis, set `cluster_mode = true`, and provide the same base64-encoded 32-byte `session_encryption_key` on every node. Redis is configured independently from the database and can use either a direct URL or Redis Sentinel discovery.
```toml
# Default: SQLite under data_dir.
database_backend = "sqlite"
# database_url = "sqlite:///var/lib/rustbox/rustbox.sqlite?mode=rwc"
session_backend = "database"
# Shared SQL example:
# database_backend = "pgsql"
# database_url = "postgres://rustbox:secret@db/rustbox"
# cluster_mode = true
# session_encryption_key = "<base64-32-byte-key>"
# Redis example; the application database remains configured separately above.
# session_backend = "redis"
# redis_url = "redis://redis:6379/"
# Sentinel alternative to redis_url:
# redis_sentinel_nodes = ["sentinel-a:26379", "sentinel-b:26379", "sentinel-c:26379"]
# redis_sentinel_master = "mymaster"
# redis_sentinel_password = "sentinel-secret"
# redis_password = "secret"
```
All executable application SQL is centralized by dialect under `src/queries/{sqlite,mysql,pgsql}/queries.rs`. The migration tree is flat per dialect: `migrations/sqlite/`, `migrations/mysql/`, and `migrations/pgsql/`. SQLite user-schema migrations are `V001`-`V046`, and the session schema is `V047__sessions.sql` for each SQL dialect.
## Development
@@ -106,7 +138,7 @@ RustBox uses a compact three-pane mail layout: folders, message list and reader.
IMAP IDLE events are delivered to the browser over the embedded WebSocket endpoint for realtime refresh and optional desktop notifications. Notification preferences include sound, folder selection and Do Not Disturb hours.
Appearance preferences are persisted per account and include light/dark/system theme, density, preview position, UI scale, multiple color presets, custom accent/background/panel/text colors, font, row height, pane widths, radius, contrast and visibility toggles. Administrators can set the global light/dark mode with `default_theme = "system" | "dark" | "light"` and the global interface style with `default_interface_skin = "rustbox" | "gmail" | "proton" | "zoho" | "outlook" | ...` in TOML. The global interface style is used on the login screen and by accounts set to **Global default**. Built-in interface styles include RustBox, Roundcube variants, Clean, Graphite, High contrast, Gmail/Proton/Zoho/Outlook/Fastmail/Apple Mail/Yahoo/HEY-inspired profiles, Nord and Minimal. Predefined styles can optionally unlock the per-account UI scale control. The mailbox toolbar also exposes persisted date/sender/subject/size sorting, and remote images can be restricted to saved contacts.
Appearance preferences are persisted per account and include light/dark/system theme, density, preview position, UI scale, multiple color presets, custom accent/background/panel/text colors, font, row height, pane widths, radius, contrast and visibility toggles. Administrators can set the global light/dark mode with `default_theme = "system" | "dark" | "light"` and the global interface style with `default_interface_skin = "rustbox" | "gmail" | "proton" | "zoho" | "outlook" | ...` in TOML. The global interface style is used on the login screen and by accounts set to **Global default**. Built-in interface styles include RustBox, Roundcube variants, Clean, Graphite, High contrast, Gmail/Proton/Zoho/Outlook/Fastmail/Apple Mail/Yahoo/HEY/GitHub/GitLab/Discord/Slack/Reddit/Notion-inspired profiles, Nord and Minimal. Predefined styles can optionally unlock the per-account UI scale control. The mailbox toolbar also exposes persisted date/sender/subject/size sorting, and remote images can be restricted to saved contacts.
The native RustBox interface intentionally uses flat surfaces, small radii and one SVG icon set. Compatibility skins may reproduce upstream visual treatments such as gradients, while toolbar icons remain SVG rather than Unicode glyphs.
@@ -138,7 +170,7 @@ Initialize Git after extracting the release archive:
```bash
git init
git add .
git commit -m "RustBox 0.0.6 release"
git commit -m "RustBox 0.1.0 release"
./scripts/setup-upstream.sh
```
+22 -8
View File
@@ -1,10 +1,10 @@
RustBox 0.0.6 source release
RustBox 0.1.0 source release
Runtime/source model:
- one root Cargo package
- src/ Rust application code
- src/web/ RustBox-owned embedded browser UI
- migrations/ SQLite migrations
- migrations/ dialect-separated SQLite/MySQL/PostgreSQL migrations
- no TypeScript/React/Next.js/Node.js/Bun build
- ManageSieve TLS/STARTTLS/explicit insecure transport option
- native systemd packaging
@@ -19,7 +19,19 @@ Oxi tracking:
- Oxi frontend changes are reviewed and manually ported to src/web/
- docs/UI.md documents the browser UI/build conventions
Current 0.0.6 release additions:
Current 0.1.0 release additions:
- persistent restart-safe sessions; SQLite under data_dir is the zero-config default
- optional MySQL/PostgreSQL session databases
- optional Redis sessions with direct URL or Redis Sentinel master discovery/failover lookup
- shared-session validation for load-balanced cluster traffic and cross-node revocation
- AES-256-GCM encryption for persisted IMAP/SMTP session credentials
- all executable Rust SQL centralized under src/queries/{sqlite,mysql,pgsql}/queries.rs, including sessions
- cached-message query projections are kept in a strict 16-column contract matching Rust row decoding
- flat migrations under migrations/{sqlite,mysql,pgsql}; SQLite user schema uses V001-V046 and sessions use V047 per SQL dialect
- user provisioning uses only the canonical SQLite V001-V046 runner; session V047 is selected separately by the configured session backend and Refinery is no longer used
- allowed downloads explicitly from the sandboxed HTML message reader while retaining popup isolation
- added GitHub-, GitLab-, Discord-, Slack-, Reddit- and Notion-inspired interface styles in light and dark variants
- added release contracts that require every predefined interface style to be visible in the selector, backed by CSS and accepted by backend/config validation
- attachment left-click defaults to safe in-app preview for supported types
- attachment right-click exposes explicit Preview and Download actions
- oversized preview attempts offer direct download instead of ending with a warning
@@ -34,7 +46,7 @@ Current 0.0.6 release additions:
- Gmail-, Proton-, Zoho- and Outlook-inspired interface profiles plus Minimal
- expanded Polish localization, including scoped dialog/settings text and common attributes
- JavaScript source split into functional `src/web/js/` modules and assembled by Cargo build.rs
- Interface style selector expanded to RustBox custom, Roundcube Classic/Larry/Elastic, Clean, Graphite, High contrast, Gmail-, Proton-, Zoho-, Outlook-inspired and Minimal
- Interface style selector includes RustBox custom, Roundcube Classic/Larry/Elastic, Clean, Graphite, High contrast, Gmail/Proton/Zoho/Outlook/Fastmail/Apple Mail/Yahoo/HEY/GitHub/GitLab/Discord/Slack/Reddit/Notion-inspired profiles, Nord and Minimal
- legacy Compact/Balanced/Comfortable/Reading appearance cards removed
- persisted special folders and mailbox behavior preferences
- bulk IMAP actions, copy/move, folder mark-all-read/empty, message-to-folder drag/drop
@@ -46,7 +58,7 @@ Current 0.0.6 release additions:
- expanded contacts with multiple e-mails/phones/addresses, vCard profile fields, duplicate merge and validated PNG/JPEG/WebP photos
- calendar Month/Week/Day/Agenda views, richer event metadata and ICS import/export
- search-index status/rebuild, IMAP reconnect diagnostics and live cross-browser session revoke UI
- 45 contiguous SQLite migrations wired sequentially; V042 adds per-user trusted senders, V043 saved responses, V044 interface skins and V045 saved-response format
- 46 contiguous SQLite user migrations wired sequentially as V001-V046; V047 is reserved for the separate session store in each SQL dialect
- safe Undo for resolvable IMAP MOVE actions and select-all across complete cached search results
- exact-token thread matching and expanded keyboard selection/navigation shortcuts
- in-app attachment preview, clipboard CID image paste and upload progress
@@ -62,10 +74,12 @@ Current 0.0.6 release additions:
- corrected Settings header/content grid for short panels and scroll-safe navigation
Validation note:
- JavaScript, JSON, TOML, SVG, shell syntax, UI selector/icon integrity and SQLite migrations were validated in the release environment.
- A full cargo build/test was not executed in this environment because cargo/rustc are not installed; run ./dev.sh or cargo test on a Rust toolchain host before deployment.
- 56 Python release/runtime/query-layout contract tests passed, including fresh SQLite user migrations, separate SQLite session migration, session restart persistence, query-catalog parity and warning-hygiene contracts.
- The assembled browser JavaScript bundle passed `node --check`; shell scripts passed `bash -n`; JSON/TOML/Python sources and Cargo.lock parsed successfully.
- `scripts/build-release.sh` now enforces `cargo fmt --check`, `cargo test --locked`, `cargo clippy --all-targets -- -D warnings`, and `cargo build --release --locked`.
- A full Rust gate could not be executed in this environment because cargo/rustc are unavailable; the release script and CI must pass on the toolchain host before deployment.
0.0.6 additions:
0.1.0 additions:
- mobile reader action and attachment fixes
- WebSocket heartbeat, reconnect backoff, watchdog, resync, dedupe and fallback polling
- reliable live new-message refresh and unread reconciliation
+18
View File
@@ -11,6 +11,22 @@ default_interface_skin = "rustbox"
data_dir = ".dev/data"
session_timeout_hours = 24
# Persistent sessions. With no extra configuration, sessions use SQLite at
# data_dir/rustbox.sqlite and survive process restarts.
database_backend = "sqlite" # sqlite | mysql | pgsql
# database_url = "postgres://rustbox:secret@db.example.com/rustbox"
session_backend = "database" # database | redis
cluster_mode = false
# For shared DB/Redis or cluster mode provide the same 32-byte key (base64) on all nodes.
# session_encryption_key = "..."
# Direct Redis session backend:
# redis_url = "redis://redis.example.com:6379/"
# Or Redis Sentinel:
# redis_sentinel_nodes = ["sentinel-a:26379", "sentinel-b:26379", "sentinel-c:26379"]
# redis_sentinel_master = "mymaster"
# redis_sentinel_password = "sentinel-secret"
# redis_password = "secret"
# Mail server configuration is administrator-owned. If omitted, development
# also defaults to localhost:993 (IMAP) and localhost:587 (SMTP).
# Certificate verification is disabled automatically only for loopback hosts.
@@ -27,3 +43,5 @@ sieve_allow_insecure_auth = false
sieve_timeout_secs = 10
trusted_proxies = "127.0.0.1,::1"
imap_host = "mail.linuxiarz.pl"
smtp_host = "mail.linuxiarz.pl"
+18 -2
View File
@@ -1,4 +1,4 @@
# RustBox 0.0.6
# RustBox 0.1.0
host = "127.0.0.1"
port = 3001
environment = "production"
@@ -6,11 +6,27 @@ environment = "production"
default_theme = "system"
# Global interface style used by the login screen and by accounts set to
# "Global default". Presets include gmail, proton, zoho, outlook, fastmail,
# apple-mail, yahoo, hey, nord, minimal and Roundcube variants.
# apple-mail, yahoo, hey, nord, github, gitlab, discord, slack, reddit, notion, minimal and Roundcube variants.
default_interface_skin = "gmail"
data_dir = "/var/lib/rustbox"
log_dir = "/var/log/rustbox"
session_timeout_hours = 24
# Persistent sessions. With no extra configuration, sessions use SQLite at
# data_dir/rustbox.sqlite and survive process restarts.
database_backend = "sqlite" # sqlite | mysql | pgsql
# database_url = "postgres://rustbox:secret@db.example.com/rustbox"
session_backend = "database" # database | redis
cluster_mode = false
# For shared DB/Redis or cluster mode provide the same 32-byte key (base64) on all nodes.
# session_encryption_key = "..."
# Direct Redis session backend:
# redis_url = "redis://redis.example.com:6379/"
# Or Redis Sentinel:
# redis_sentinel_nodes = ["sentinel-a:26379", "sentinel-b:26379", "sentinel-c:26379"]
# redis_sentinel_master = "mymaster"
# redis_sentinel_password = "sentinel-secret"
# redis_password = "secret"
remember_me_enabled = true
remember_me_days = 30
+2 -3
View File
@@ -104,8 +104,7 @@ This file tracks the internal `0.0.4` baseline. The user-approved `docs/RUSTBOX_
- Fixed the `E0499` mutable-borrow failure in `mark_all_read`: the `uid_store()` stream is dropped before `session.logout()`.
- Fixed a duplicate `else` introduced in the calendar delete handler during the calendar expansion.
- All 45 SQLite migrations are present, contiguous and wired sequentially in the migration runner during static validation; V042 adds trusted senders, V043 saved responses, V044 persisted interface skins and V045 saved-response format.
- All executable Rust SQL is centralized under `src/queries/{sqlite,mysql,pgsql}/queries.rs`, including session queries.
## Partially implemented - keep visible as incomplete
- Sieve visual builder now supports a three-condition nested AND/OR group, negation, two actions, capability-aware disabling, managed-rule ordering and scheduled vacation dates. An arbitrary-depth condition tree, arbitrary-length action stack and full capability-aware condition/action matrix remain unfinished.
@@ -124,7 +123,7 @@ This file tracks the internal `0.0.4` baseline. The user-approved `docs/RUSTBOX_
- Spam backend adapters for rspamd/SpamAssassin/custom command.
- Calendar Month/Week/Day/Agenda views are present; recurrence expansion/exceptions, invitation response workflow and CalDAV remain.
- Full CardDAV synchronization.
- Password-change adapters and persistent cross-restart session inventory/device metadata. Live cross-browser sessions can already be listed/revoked while the process is running.
- Password-change adapters remain unfinished. Session inventory/device metadata is now persistent across restarts and shared-session backends support cross-node listing/revocation.
- Diagnostics now includes IMAP reconnect, SMTP connectivity, index rebuild and last local folder/message synchronization metadata; deeper background task/queue metrics and repair workflows remain.
- Full security, acceptance and performance matrix from `docs/RUSTBOX_PLAN.md`.
+2 -3
View File
@@ -21,8 +21,7 @@ Release source checks:
Full release gate on a host with Rust/Cargo and dependency access:
```bash
cargo fmt --all -- --check
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
./scripts/build-release.sh
```
The release script runs formatting validation, locked tests, Clippy with warnings denied, and a locked release build before accepting the binary.
+1 -1
View File
@@ -6,7 +6,7 @@ use std::path::Path;
#[path = "../src/email_theme.rs"]
mod email_theme;
use email_theme::{detect_email_theme, EmailTheme};
use email_theme::{EmailTheme, detect_email_theme};
fn main() {
let debugging_dir = Path::new("../debugging");
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR(64) PRIMARY KEY,
account_id VARCHAR(64) NOT NULL UNIQUE,
browser_id VARCHAR(64) NOT NULL,
email VARCHAR(320) NOT NULL,
password_ciphertext TEXT NOT NULL,
user_hash VARCHAR(128) NOT NULL,
imap_host VARCHAR(255) NOT NULL,
imap_port BIGINT NOT NULL,
imap_tls BIGINT NOT NULL,
imap_tls_verify BIGINT NOT NULL,
smtp_host VARCHAR(255) NOT NULL,
smtp_port BIGINT NOT NULL,
smtp_tls BIGINT NOT NULL,
smtp_tls_verify BIGINT NOT NULL,
timeout_secs BIGINT NOT NULL,
created_at_unix BIGINT NOT NULL,
last_accessed_unix BIGINT NOT NULL,
user_agent VARCHAR(512) NOT NULL DEFAULT '',
INDEX idx_sessions_browser (browser_id),
INDEX idx_sessions_user (user_hash),
INDEX idx_sessions_expiry (last_accessed_unix, timeout_secs)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR(64) PRIMARY KEY,
account_id VARCHAR(64) NOT NULL UNIQUE,
browser_id VARCHAR(64) NOT NULL,
email VARCHAR(320) NOT NULL,
password_ciphertext TEXT NOT NULL,
user_hash VARCHAR(128) NOT NULL,
imap_host VARCHAR(255) NOT NULL,
imap_port BIGINT NOT NULL,
imap_tls BIGINT NOT NULL,
imap_tls_verify BIGINT NOT NULL,
smtp_host VARCHAR(255) NOT NULL,
smtp_port BIGINT NOT NULL,
smtp_tls BIGINT NOT NULL,
smtp_tls_verify BIGINT NOT NULL,
timeout_secs BIGINT NOT NULL,
created_at_unix BIGINT NOT NULL,
last_accessed_unix BIGINT NOT NULL,
user_agent VARCHAR(512) NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_sessions_browser ON sessions(browser_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(last_accessed_unix, timeout_secs);
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY NOT NULL,
account_id TEXT NOT NULL UNIQUE,
browser_id TEXT NOT NULL,
email TEXT NOT NULL,
password_ciphertext TEXT NOT NULL,
user_hash TEXT NOT NULL,
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL,
imap_tls INTEGER NOT NULL,
imap_tls_verify INTEGER NOT NULL,
smtp_host TEXT NOT NULL,
smtp_port INTEGER NOT NULL,
smtp_tls INTEGER NOT NULL,
smtp_tls_verify INTEGER NOT NULL,
timeout_secs INTEGER NOT NULL,
created_at_unix INTEGER NOT NULL,
last_accessed_unix INTEGER NOT NULL,
user_agent TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_sessions_browser ON sessions(browser_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(last_accessed_unix, timeout_secs);
+115
View File
@@ -1,31 +1,146 @@
# RustBox 0.1.0 - packaged production configuration (DEB/RPM)
#
# The active values below intentionally keep the existing package profile.
# Alternative storage/cluster examples are provided at the end of this file
# and are fully commented out, so they do not change the default package setup.
# -----------------------------------------------------------------------------
# Server
# -----------------------------------------------------------------------------
host = "0.0.0.0"
port = 3001
environment = "production"
data_dir = "/var/lib/rustbox"
log_dir = "/var/log/rustbox"
# Sessions and "remember me".
# With no database/session backend keys configured, RustBox defaults to
# persistent SQLite sessions under data_dir and survives process restarts.
session_timeout_hours = 72
remember_me_enabled = true
remember_me_days = 30
# -----------------------------------------------------------------------------
# Mail endpoints
# -----------------------------------------------------------------------------
imap_host = "localhost"
imap_port = 993
smtp_host = "localhost"
smtp_port = 587
tls_enabled = true
# This packaged profile intentionally keeps certificate verification disabled.
# For remote production mail servers, tls_verify = true is recommended.
tls_verify = false
# Size limits in MiB.
max_attachment_size_mb = 50
max_message_size_mb = 100
# -----------------------------------------------------------------------------
# ManageSieve
# -----------------------------------------------------------------------------
sieve_port = 4190
sieve_tls_mode = "none"
sieve_allow_insecure_auth = true
sieve_timeout_secs = 10
# -----------------------------------------------------------------------------
# Mailbox/UI defaults
# -----------------------------------------------------------------------------
default_junk_folder = "Spam"
default_theme = "system"
default_interface_skin = "apple-mail"
# -----------------------------------------------------------------------------
# Reverse proxy and outgoing mail
# -----------------------------------------------------------------------------
trusted_proxies = "127.0.0.1,::1"
outgoing_headers = []
# -----------------------------------------------------------------------------
# OpenPGP
# -----------------------------------------------------------------------------
pgp_wkd_enabled = true
pgp_hkp_enabled = false
pgp_hkp_url = "https://keys.openpgp.org"
pgp_network_timeout_secs = 10
pgp_max_key_bytes = 1048576
pgp_allow_private_export = true
# =============================================================================
# OPTIONAL CONFIGURATION EXAMPLES
# =============================================================================
# Do not enable more than one conflicting example at the same time.
# Copy/uncomment only the keys required by the selected profile.
#
# session_encryption_key must represent the same 32-byte key on every node that
# shares a SQL/Redis session store. Keep it secret and outside source control.
# -----------------------------------------------------------------------------
# Profile A: standalone SQLite sessions (default package behavior)
# -----------------------------------------------------------------------------
# No extra keys are required. The equivalent explicit configuration is:
# database_backend = "sqlite"
# session_backend = "database"
# cluster_mode = false
#
# RustBox stores sessions in data_dir/rustbox.sqlite and creates/uses the local
# session encryption key for this single-node SQLite setup.
# -----------------------------------------------------------------------------
# Profile B: PostgreSQL-backed persistent sessions
# -----------------------------------------------------------------------------
# database_backend = "pgsql"
# database_url = "postgres://rustbox:change-me@127.0.0.1/rustbox"
# session_backend = "database"
# cluster_mode = false
# session_encryption_key = "BASE64_32_BYTE_KEY"
# -----------------------------------------------------------------------------
# Profile C: MySQL-backed persistent sessions
# -----------------------------------------------------------------------------
# database_backend = "mysql"
# database_url = "mysql://rustbox:change-me@127.0.0.1/rustbox"
# session_backend = "database"
# cluster_mode = false
# session_encryption_key = "BASE64_32_BYTE_KEY"
# -----------------------------------------------------------------------------
# Profile D: Redis sessions, single application node
# -----------------------------------------------------------------------------
# session_backend = "redis"
# redis_url = "redis://127.0.0.1:6379/"
# cluster_mode = false
# session_encryption_key = "BASE64_32_BYTE_KEY"
#
# If Redis requires authentication, include credentials in redis_url or use the
# supported Redis password configuration for your deployment.
# -----------------------------------------------------------------------------
# Profile E: Redis Sentinel sessions for multiple RustBox nodes
# -----------------------------------------------------------------------------
# session_backend = "redis"
# cluster_mode = true
# redis_sentinel_nodes = ["sentinel-a:26379", "sentinel-b:26379", "sentinel-c:26379"]
# redis_sentinel_master = "mymaster"
# redis_sentinel_password = "sentinel-secret"
# redis_password = "redis-secret"
# session_encryption_key = "BASE64_32_BYTE_KEY"
# -----------------------------------------------------------------------------
# Profile F: PostgreSQL sessions shared by a RustBox application cluster
# -----------------------------------------------------------------------------
# database_backend = "pgsql"
# database_url = "postgres://rustbox:change-me@db.internal/rustbox"
# session_backend = "database"
# cluster_mode = true
# session_encryption_key = "BASE64_32_BYTE_KEY"
# -----------------------------------------------------------------------------
# Profile G: MySQL sessions shared by a RustBox application cluster
# -----------------------------------------------------------------------------
# database_backend = "mysql"
# database_url = "mysql://rustbox:change-me@db.internal/rustbox"
# session_backend = "database"
# cluster_mode = true
# session_encryption_key = "BASE64_32_BYTE_KEY"
+9
View File
@@ -0,0 +1,9 @@
RustBox: http://127.0.0.1:3001
Config: /home/mg/RustBox/config.dev.toml
Data: /home/mg/RustBox/.dev/data
RustBox configuration OK
{"timestamp":"2026-10-30T15:14:14.084058Z","level":"DEBUG","fields":{"message":"Purged expired sessions"},"target":"rustbox"}
{"timestamp":"2026-10-30T15:14:14.085996Z","level":"INFO","fields":{"message":"RustBox server starting","host":"127.0.0.1","port":"3001"},"target":"rustbox"}
{"timestamp":"2026-10-30T15:14:15.105150Z","level":"INFO","fields":{"message":"Shutdown signal received; stopping IDLE tasks"},"target":"rustbox"}
{"timestamp":"2026-10-30T15:14:15.105664Z","level":"INFO","fields":{"message":"RustBox server stopped"},"target":"rustbox"}
+3
View File
@@ -19,7 +19,10 @@ if [[ -e "$ROOT/src/web/app.js" || ! -f "$ROOT/src/web/js/manifest.txt" ]]; then
exit 1
fi
cargo fmt --all
cargo fmt --all -- --check
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo build --release --locked
BIN="$ROOT/target/release/rustbox"
+60 -23
View File
@@ -10,7 +10,8 @@ use super::session::SessionStore;
pub const BROWSER_COOKIE: &str = "rustbox_browser";
pub const ACTIVE_ACCOUNT_HEADER: &str = "X-Active-Account";
const UNAUTHORIZED_BODY: &str = r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid or expired session","status":401}}"#;
const UNAUTHORIZED_BODY: &str =
r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid or expired session","status":401}}"#;
fn unauthorized() -> Response {
(
@@ -117,7 +118,8 @@ pub async fn auth_guard(mut req: Request, next: Next) -> Response {
None => return unauthorized(),
};
let account_id = match extract_active_account_header(&req).or_else(|| extract_active_account_query(&req)) {
let account_id =
match extract_active_account_header(&req).or_else(|| extract_active_account_query(&req)) {
Some(a) => a,
None => return unauthorized(),
};
@@ -127,7 +129,10 @@ pub async fn auth_guard(mut req: Request, next: Next) -> Response {
None => return account_expired(&account_id),
};
let session = match store.get_account_session(&browser_id, &account_id, &token) {
let session = match store
.get_account_session_shared(&browser_id, &account_id, &token)
.await
{
Some(s) => s,
None => return account_expired(&account_id),
};
@@ -157,7 +162,9 @@ mod tests {
fn guarded_router(store: Arc<SessionStore>) -> Router {
let handler = |Extension(session): Extension<AccountSession>| async move {
serde_json::json!({ "email": session.email }).to_string().into_response()
serde_json::json!({ "email": session.email })
.to_string()
.into_response()
};
Router::new()
@@ -166,10 +173,7 @@ mod tests {
.layer(Extension(store))
}
async fn send(
router: Router,
req: Request<Body>,
) -> (StatusCode, serde_json::Value) {
async fn send(router: Router, req: Request<Body>) -> (StatusCode, serde_json::Value) {
let resp = router.oneshot(req).await.expect("request should succeed");
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
@@ -184,7 +188,12 @@ mod tests {
token: String,
}
fn create_test_session(store: &SessionStore, email: &str, password: &str, user_hash: &str) -> TestSession {
fn create_test_session(
store: &SessionStore,
email: &str,
password: &str,
user_hash: &str,
) -> TestSession {
let browser_id = store.create_browser();
let (token, account_id) = store.add_account_to_browser(
&browser_id,
@@ -198,7 +207,11 @@ mod tests {
587,
true,
);
TestSession { browser_id, account_id, token }
TestSession {
browser_id,
account_id,
token,
}
}
fn build_auth_headers(session: &TestSession) -> String {
@@ -247,7 +260,10 @@ mod tests {
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", format!("rustbox_session_{}={}", session.account_id, session.token))
.header(
"cookie",
format!("rustbox_session_{}={}", session.account_id, session.token),
)
.body(Body::empty())
.unwrap();
@@ -304,10 +320,13 @@ mod tests {
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", format!(
.header(
"cookie",
format!(
"rustbox_browser={}; rustbox_session_{}=invalid-token",
session.browser_id, session.account_id
))
),
)
.body(Body::empty())
.unwrap();
@@ -347,10 +366,13 @@ mod tests {
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", format!(
.header(
"cookie",
format!(
"rustbox_browser={}; rustbox_session_{}={}",
wrong_browser_id, session.account_id, session.token
))
),
)
.body(Body::empty())
.unwrap();
@@ -364,7 +386,7 @@ mod tests {
#[tokio::test]
async fn expired_session_returns_account_expired() {
let store = Arc::new(SessionStore::new(Duration::from_millis(50)));
let session = create_test_session(&store.as_ref(), "bob@example.com", "pass", "hash");
let session = create_test_session(store.as_ref(), "bob@example.com", "pass", "hash");
thread::sleep(Duration::from_millis(100));
@@ -418,10 +440,13 @@ mod tests {
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", format!(
.header(
"cookie",
format!(
"other_browser={}; rustbox_session_{}={}",
session.browser_id, session.account_id, session.token
))
),
)
.body(Body::empty())
.unwrap();
@@ -472,7 +497,10 @@ mod tests {
.body(Body::empty())
.unwrap();
assert_eq!(extract_active_account_header(&req), Some("account-123".to_string()));
assert_eq!(
extract_active_account_header(&req),
Some("account-123".to_string())
);
}
#[tokio::test]
@@ -492,7 +520,10 @@ mod tests {
.body(Body::empty())
.unwrap();
assert_eq!(extract_active_account_query(&req), Some("account-123".to_string()));
assert_eq!(
extract_active_account_query(&req),
Some("account-123".to_string())
);
}
#[tokio::test]
@@ -502,7 +533,10 @@ mod tests {
.body(Body::empty())
.unwrap();
assert_eq!(extract_active_account_query(&req), Some("abc@example.com".to_string()));
assert_eq!(
extract_active_account_query(&req),
Some("abc@example.com".to_string())
);
}
#[tokio::test]
@@ -543,10 +577,13 @@ mod tests {
let req = Request::builder()
.uri(format!("/protected?account_id={}", session2.account_id))
.header(ACTIVE_ACCOUNT_HEADER, &session1.account_id)
.header("cookie", format!(
.header(
"cookie",
format!(
"rustbox_browser={}; rustbox_session_{}={}",
session1.browser_id, session1.account_id, session1.token
))
),
)
.body(Body::empty())
.unwrap();
+371 -38
View File
@@ -6,12 +6,29 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use dashmap::DashMap;
use rand::RngCore;
use crate::session_store::crypto::SessionCrypto;
use crate::session_store::{PersistedSession, SessionPersistence};
pub type SessionId = String;
pub type BrowserId = String;
pub type AccountId = String;
pub type SessionState = AccountSession;
#[derive(Debug, Clone)]
pub struct NewAccountSession {
pub email: String,
pub password: String,
pub user_hash: String,
pub imap_host: String,
pub imap_port: u16,
pub imap_tls: bool,
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_tls: bool,
pub timeout_override: Option<Duration>,
}
#[derive(Debug, Clone)]
pub struct AccountSession {
pub account_id: AccountId,
@@ -37,7 +54,7 @@ pub struct AccountSession {
pub user_agent: String,
}
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct SessionStore {
sessions: Arc<DashMap<SessionId, AccountSession>>,
browsers: Arc<DashMap<BrowserId, Vec<AccountId>>>,
@@ -45,6 +62,8 @@ pub struct SessionStore {
account_to_browser: Arc<DashMap<AccountId, BrowserId>>,
timeout: Duration,
tls_verify_override: Option<bool>,
persistence: Option<Arc<dyn SessionPersistence>>,
crypto: Option<SessionCrypto>,
}
impl SessionStore {
@@ -55,10 +74,12 @@ impl SessionStore {
.as_secs()
}
#[cfg(test)]
pub fn new(timeout: Duration) -> Self {
Self::new_with_tls_verify(timeout, None)
}
#[cfg(test)]
pub fn new_with_tls_verify(timeout: Duration, tls_verify_override: Option<bool>) -> Self {
Self {
sessions: Arc::new(DashMap::new()),
@@ -67,6 +88,26 @@ impl SessionStore {
account_to_browser: Arc::new(DashMap::new()),
timeout,
tls_verify_override,
persistence: None,
crypto: None,
}
}
pub fn new_persistent(
timeout: Duration,
tls_verify_override: Option<bool>,
persistence: Arc<dyn SessionPersistence>,
crypto: SessionCrypto,
) -> Self {
Self {
sessions: Arc::new(DashMap::new()),
browsers: Arc::new(DashMap::new()),
account_to_session: Arc::new(DashMap::new()),
account_to_browser: Arc::new(DashMap::new()),
timeout,
tls_verify_override,
persistence: Some(persistence),
crypto: Some(crypto),
}
}
@@ -99,6 +140,7 @@ impl SessionStore {
browser_id
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub fn add_account_to_browser(
&self,
@@ -270,43 +312,6 @@ impl SessionStore {
result
}
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
let Some(session_id) = self
.account_to_session
.get(account_id)
.map(|entry| entry.clone())
else {
return false;
};
let belongs_to_user = self
.sessions
.get(&session_id)
.is_some_and(|session| session.user_hash == user_hash);
if !belongs_to_user {
return false;
}
self.remove_account(account_id)
}
pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize {
let account_ids: Vec<String> = self
.sessions
.iter()
.filter(|entry| {
entry.value().user_hash == user_hash
&& entry.value().account_id != current_account_id
})
.map(|entry| entry.value().account_id.clone())
.collect();
let mut removed = 0;
for account_id in account_ids {
if self.remove_user_session(user_hash, &account_id) {
removed += 1;
}
}
removed
}
pub fn remove_browser(&self, browser_id: &str) {
if let Some((_, account_ids)) = self.browsers.remove(browser_id) {
for account_id in account_ids {
@@ -474,6 +479,282 @@ impl SessionStore {
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
fn cache_persisted(&self, record: &PersistedSession) -> Result<AccountSession, String> {
let crypto = self
.crypto
.as_ref()
.ok_or("session crypto is not configured")?;
let password = crypto.decrypt(&record.password_ciphertext)?;
let age = Self::unix_now().saturating_sub(record.last_accessed_unix);
let now = Instant::now();
let last_accessed = now.checked_sub(Duration::from_secs(age)).unwrap_or(now);
let timeout_override = if record.timeout_secs == self.timeout.as_secs() {
None
} else {
Some(Duration::from_secs(record.timeout_secs))
};
let session = AccountSession {
account_id: record.account_id.clone(),
email: record.email.clone(),
password,
user_hash: record.user_hash.clone(),
imap_host: record.imap_host.clone(),
imap_port: record.imap_port,
imap_tls: record.imap_tls,
imap_tls_verify: record.imap_tls_verify,
smtp_host: record.smtp_host.clone(),
smtp_port: record.smtp_port,
smtp_tls: record.smtp_tls,
smtp_tls_verify: record.smtp_tls_verify,
last_accessed,
timeout_override,
created_at_unix: record.created_at_unix,
last_accessed_unix: record.last_accessed_unix,
user_agent: record.user_agent.clone(),
};
self.sessions.insert(record.token.clone(), session.clone());
self.account_to_session
.insert(record.account_id.clone(), record.token.clone());
self.account_to_browser
.insert(record.account_id.clone(), record.browser_id.clone());
let mut entry = self.browsers.entry(record.browser_id.clone()).or_default();
if !entry.contains(&record.account_id) {
entry.push(record.account_id.clone());
}
Ok(session)
}
fn persisted_from_memory(
&self,
token: &str,
browser_id: &str,
session: &AccountSession,
) -> Result<PersistedSession, String> {
let crypto = self
.crypto
.as_ref()
.ok_or("session crypto is not configured")?;
Ok(PersistedSession {
token: token.to_string(),
account_id: session.account_id.clone(),
browser_id: browser_id.to_string(),
email: session.email.clone(),
password_ciphertext: crypto.encrypt(&session.password)?,
user_hash: session.user_hash.clone(),
imap_host: session.imap_host.clone(),
imap_port: session.imap_port,
imap_tls: session.imap_tls,
imap_tls_verify: session.imap_tls_verify,
smtp_host: session.smtp_host.clone(),
smtp_port: session.smtp_port,
smtp_tls: session.smtp_tls,
smtp_tls_verify: session.smtp_tls_verify,
timeout_secs: session.timeout_override.unwrap_or(self.timeout).as_secs(),
created_at_unix: session.created_at_unix,
last_accessed_unix: session.last_accessed_unix,
user_agent: session.user_agent.clone(),
})
}
async fn persist_account(&self, browser_id: &str, account_id: &str) {
let (Some(persistence), Some(token)) = (
self.persistence.as_ref(),
self.account_to_session.get(account_id).map(|v| v.clone()),
) else {
return;
};
let Some(session) = self.sessions.get(&token).map(|v| v.clone()) else {
return;
};
match self.persisted_from_memory(&token, browser_id, &session) {
Ok(record) => {
if let Err(error) = persistence.save(&record).await {
tracing::error!(%error, "failed to persist session");
}
}
Err(error) => tracing::error!(%error, "failed to encrypt session"),
}
}
pub async fn add_account_to_browser_shared(
&self,
browser_id: &str,
input: NewAccountSession,
) -> (SessionId, AccountId) {
let result = self.add_account_to_browser_with_timeout(
browser_id,
input.email,
input.password,
input.user_hash,
input.imap_host,
input.imap_port,
input.imap_tls,
input.smtp_host,
input.smtp_port,
input.smtp_tls,
input.timeout_override,
);
self.persist_account(browser_id, &result.1).await;
result
}
pub async fn get_browser_accounts_shared(&self, browser_id: &str) -> Vec<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get_browser_accounts(browser_id);
};
match persistence.load_browser(browser_id).await {
Ok(records) => records
.into_iter()
.filter(|r| !r.expired_at(Self::unix_now()))
.filter_map(|r| {
self.cache_persisted(&r)
.map_err(|e| tracing::error!(error=%e, "failed to restore session"))
.ok()
})
.collect(),
Err(error) => {
tracing::error!(%error, "failed to load browser sessions");
self.get_browser_accounts(browser_id)
}
}
}
pub async fn browser_exists_shared(&self, browser_id: &str) -> bool {
let Some(persistence) = self.persistence.as_ref() else {
return self.browser_exists(browser_id);
};
persistence
.browser_exists(browser_id)
.await
.unwrap_or_else(|error| {
tracing::error!(%error, "failed to query browser session");
false
})
}
pub async fn get_shared(&self, token: &str) -> Option<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get(token);
};
let record = persistence.load_token(token).await.ok().flatten()?;
if record.expired_at(Self::unix_now()) {
let _ = persistence.delete_account(&record.account_id).await;
return None;
}
self.cache_persisted(&record).ok()?;
let refreshed = self.get(token)?;
self.persist_account(&record.browser_id, &record.account_id)
.await;
Some(refreshed)
}
pub async fn get_account_session_shared(
&self,
browser_id: &str,
account_id: &str,
token: &str,
) -> Option<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get_account_session(browser_id, account_id, token);
};
let record = persistence.load_token(token).await.ok().flatten()?;
if record.browser_id != browser_id
|| record.account_id != account_id
|| record.expired_at(Self::unix_now())
{
return None;
}
self.cache_persisted(&record).ok()?;
let session = self.get(token)?;
self.persist_account(browser_id, account_id).await;
Some(session)
}
pub async fn set_user_agent_shared(&self, account_id: &str, user_agent: String) {
self.set_user_agent(account_id, user_agent);
if let Some(browser_id) = self.account_to_browser.get(account_id).map(|v| v.clone()) {
self.persist_account(&browser_id, account_id).await;
}
}
pub async fn remove_account_shared(&self, account_id: &str) -> bool {
let local = self.remove_account(account_id);
let Some(persistence) = self.persistence.as_ref() else {
return local;
};
match persistence.delete_account(account_id).await {
Ok(remote) => local || remote,
Err(error) => {
tracing::error!(%error, "failed to delete persisted session");
local
}
}
}
pub async fn remove_browser_shared(&self, browser_id: &str) {
self.remove_browser(browser_id);
if let Some(persistence) = self.persistence.as_ref()
&& let Err(error) = persistence.delete_browser(browser_id).await
{
tracing::error!(%error, "failed to delete persisted browser sessions");
}
}
pub async fn get_user_sessions_shared(&self, user_hash: &str) -> Vec<AccountSession> {
let Some(persistence) = self.persistence.as_ref() else {
return self.get_user_sessions(user_hash);
};
match persistence.load_user(user_hash).await {
Ok(records) => records
.into_iter()
.filter(|r| !r.expired_at(Self::unix_now()))
.filter_map(|r| self.cache_persisted(&r).ok())
.collect(),
Err(error) => {
tracing::error!(%error, "failed to load user sessions");
self.get_user_sessions(user_hash)
}
}
}
pub async fn remove_user_session_shared(&self, user_hash: &str, account_id: &str) -> bool {
let sessions = self.get_user_sessions_shared(user_hash).await;
if !sessions.iter().any(|s| s.account_id == account_id) {
return false;
}
self.remove_account_shared(account_id).await
}
pub async fn remove_other_user_sessions_shared(
&self,
user_hash: &str,
current_account_id: &str,
) -> usize {
let ids: Vec<String> = self
.get_user_sessions_shared(user_hash)
.await
.into_iter()
.filter(|s| s.account_id != current_account_id)
.map(|s| s.account_id)
.collect();
let mut removed = 0;
for id in ids {
if self.remove_account_shared(&id).await {
removed += 1;
}
}
removed
}
pub async fn purge_expired_shared(&self) {
self.purge_expired();
if let Some(persistence) = self.persistence.as_ref()
&& let Err(error) = persistence.purge_expired(Self::unix_now()).await
{
tracing::error!(%error, "failed to purge persisted sessions");
}
}
}
#[cfg(test)]
@@ -1318,4 +1599,56 @@ mod tests {
"remembered session should outlive default timeout"
);
}
#[tokio::test]
async fn persistent_sqlite_session_survives_store_restart() {
let temp = tempfile::tempdir().expect("tempdir");
let config = crate::config::AppConfig {
data_dir: temp.path().to_string_lossy().into_owned(),
database_backend: "sqlite".to_string(),
session_backend: "database".to_string(),
..Default::default()
};
let persistence = crate::session_store::from_config(&config)
.await
.expect("persistence");
let crypto =
crate::session_store::crypto::SessionCrypto::load_or_create(&config).expect("crypto");
let store =
SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto);
let browser_id = store.create_browser();
let (token, account_id) = store
.add_account_to_browser_shared(
&browser_id,
NewAccountSession {
email: "restart@example.com".to_string(),
password: "secret-password".to_string(),
user_hash: "user-hash".to_string(),
imap_host: "imap.example.com".to_string(),
imap_port: 993,
imap_tls: true,
smtp_host: "smtp.example.com".to_string(),
smtp_port: 587,
smtp_tls: true,
timeout_override: None,
},
)
.await;
drop(store);
let persistence = crate::session_store::from_config(&config)
.await
.expect("persistence after restart");
let crypto = crate::session_store::crypto::SessionCrypto::load_or_create(&config)
.expect("crypto after restart");
let restarted =
SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto);
let restored = restarted
.get_account_session_shared(&browser_id, &account_id, &token)
.await
.expect("session should survive restart");
assert_eq!(restored.email, "restart@example.com");
assert_eq!(restored.password, "secret-password");
}
}
+7 -13
View File
@@ -3,11 +3,6 @@ use std::path::PathBuf;
use sha2::{Digest, Sha256};
mod embedded {
use refinery::embed_migrations;
embed_migrations!("migrations");
}
pub fn hash_email(email: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(email.as_bytes());
@@ -31,13 +26,12 @@ pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result<PathBuf, S
}
}
let db_path = user_dir.join("db.sqlite");
let mut conn = rusqlite::Connection::open(&db_path)
.map_err(|e| format!("failed to open database: {e}"))?;
embedded::migrations::runner()
.run(&mut conn)
.map_err(|e| format!("failed to run migrations: {e}"))?;
// User data is always SQLite and must only receive the user-schema
// migrations from db::pool. Session migrations are handled separately by
// session_store for the configured session database backend.
let conn = crate::db::pool::open_user_db(data_dir, user_hash)
.map_err(|e| format!("failed to initialize user database: {e}"))?;
drop(conn);
Ok(user_dir)
}
@@ -117,7 +111,7 @@ mod tests {
let value: String = conn
.query_row(
"SELECT value FROM user_meta WHERE key = 'schema_version'",
crate::queries::sqlite::queries::auth_user_data::Q_001,
[],
|row| row.get(0),
)
+122 -1
View File
@@ -46,6 +46,36 @@ pub struct AppConfig {
#[serde(default)]
pub log_dir: Option<String>,
#[serde(default = "default_database_backend")]
pub database_backend: String,
#[serde(default)]
pub database_url: Option<String>,
#[serde(default = "default_session_backend")]
pub session_backend: String,
#[serde(default)]
pub session_encryption_key: Option<String>,
#[serde(default)]
pub redis_url: Option<String>,
#[serde(default)]
pub redis_sentinel_nodes: Vec<String>,
#[serde(default)]
pub redis_sentinel_master: Option<String>,
#[serde(default)]
pub redis_password: Option<String>,
#[serde(default)]
pub redis_sentinel_password: Option<String>,
#[serde(default)]
pub cluster_mode: bool,
#[serde(default = "default_session_timeout_hours")]
pub session_timeout_hours: u64,
@@ -169,6 +199,14 @@ fn default_data_dir() -> String {
"/data".to_string()
}
fn default_database_backend() -> String {
"sqlite".to_string()
}
fn default_session_backend() -> String {
"database".to_string()
}
fn default_session_timeout_hours() -> u64 {
24
}
@@ -232,6 +270,16 @@ impl Default for AppConfig {
max_message_size_mb: default_max_message_size_mb(),
data_dir: default_data_dir(),
log_dir: None,
database_backend: default_database_backend(),
database_url: None,
session_backend: default_session_backend(),
session_encryption_key: None,
redis_url: None,
redis_sentinel_nodes: Vec::new(),
redis_sentinel_master: None,
redis_password: None,
redis_sentinel_password: None,
cluster_mode: false,
session_timeout_hours: default_session_timeout_hours(),
remember_me_enabled: default_remember_me_enabled(),
remember_me_days: default_remember_me_days(),
@@ -407,6 +455,64 @@ 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 !matches!(
self.database_backend.as_str(),
"sqlite" | "mysql" | "pgsql" | "postgres" | "postgresql"
) {
return Err("DATABASE_BACKEND must be one of: sqlite, mysql, pgsql".to_string());
}
if self.database_backend != "sqlite"
&& self
.database_url
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none()
{
return Err("DATABASE_URL is required for mysql/pgsql".to_string());
}
if !matches!(self.session_backend.as_str(), "database" | "redis") {
return Err("SESSION_BACKEND must be one of: database, redis".to_string());
}
if self.session_backend == "redis" {
let direct = self
.redis_url
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_some();
let sentinel = !self.redis_sentinel_nodes.is_empty()
&& self
.redis_sentinel_master
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_some();
if !direct && !sentinel {
return Err("Redis sessions require REDIS_URL or REDIS_SENTINEL_NODES + REDIS_SENTINEL_MASTER".to_string());
}
}
if self.cluster_mode
&& self.session_backend == "database"
&& self.database_backend == "sqlite"
{
return Err("CLUSTER_MODE with database sessions requires mysql/pgsql, or configure SESSION_BACKEND=redis".to_string());
}
if (self.cluster_mode
|| self.session_backend == "redis"
|| self.database_backend != "sqlite")
&& self
.session_encryption_key
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none()
{
return Err(
"shared session storage requires SESSION_ENCRYPTION_KEY shared by all nodes"
.to_string(),
);
}
if self.session_timeout_hours == 0 || self.session_timeout_hours > 24 * 365 {
return Err("SESSION_TIMEOUT_HOURS must be between 1 and 8760".to_string());
}
@@ -435,8 +541,14 @@ impl AppConfig {
| "yahoo"
| "hey"
| "nord"
| "github"
| "gitlab"
| "discord"
| "slack"
| "reddit"
| "notion"
) {
return Err("DEFAULT_INTERFACE_SKIN must be one of: rustbox, roundcube-classic, roundcube-larry, roundcube-elastic, clean, gmail, proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail, yahoo, hey, nord".to_string());
return Err("DEFAULT_INTERFACE_SKIN must be one of: rustbox, roundcube-classic, roundcube-larry, roundcube-elastic, clean, gmail, proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail, yahoo, hey, nord, github, gitlab, discord, slack, reddit, notion".to_string());
}
if !matches!(
self.sieve_tls_mode.as_str(),
@@ -489,6 +601,15 @@ mod tests {
assert_eq!(config.max_message_size_mb, 50);
assert_eq!(config.data_dir, "/data");
assert!(config.log_dir.is_none());
assert_eq!(config.database_backend, "sqlite");
assert!(config.database_url.is_none());
assert_eq!(config.session_backend, "database");
assert!(config.session_encryption_key.is_none());
assert!(config.redis_url.is_none());
assert!(config.redis_sentinel_nodes.is_empty());
assert!(config.redis_sentinel_master.is_none());
assert!(config.redis_sentinel_password.is_none());
assert!(!config.cluster_mode);
assert_eq!(config.session_timeout_hours, 24);
assert!(config.remember_me_enabled);
assert_eq!(config.remember_me_days, 30);
+27 -52
View File
@@ -1,3 +1,4 @@
use crate::queries::sqlite::queries::db_calendar::{EVENT_SELECT_COLS, SETTINGS_SELECT_COLS};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -125,11 +126,6 @@ pub struct MeetingTemplate {
pub created_at: String,
}
const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
fn row_to_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<CalendarEvent> {
let all_day_int: i32 = row.get(6)?;
Ok(CalendarEvent {
@@ -191,11 +187,7 @@ pub fn list_events(
start: &str,
end: &str,
) -> Result<Vec<CalendarEvent>, String> {
let sql = format!(
"SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ?1 AND start_time <= ?2
ORDER BY start_time ASC"
);
let sql = crate::rustbox_query_db_calendar_001!();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare list_events: {e}"))?;
@@ -211,9 +203,7 @@ pub fn list_events(
}
pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String> {
let sql = format!(
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC"
);
let sql = crate::rustbox_query_db_calendar_002!();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare list_all_events: {e}"))?;
@@ -229,7 +219,7 @@ pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String>
}
pub fn get_event(conn: &Connection, id: &str) -> Result<Option<CalendarEvent>, String> {
let sql = format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1");
let sql = crate::rustbox_query_db_calendar_003!();
let result = conn.query_row(&sql, params![id], row_to_event);
match result {
@@ -242,16 +232,15 @@ pub fn get_event(conn: &Connection, id: &str) -> Result<Option<CalendarEvent>, S
pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEvent, String> {
let id = Uuid::new_v4().to_string();
let now: String = conn
.query_row("SELECT datetime('now')", [], |row| row.get(0))
.query_row(
crate::queries::sqlite::queries::db_calendar::Q_001,
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to get current time: {e}"))?;
conn.execute(
"INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
recurrence_rule, attendees, organizer, status, source, source_uid,
source_message_uid, source_message_folder, meeting_url, color,
reminder_minutes, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
crate::queries::sqlite::queries::db_calendar::Q_002,
params![
id,
data.title,
@@ -329,10 +318,7 @@ pub fn update_event(
sets.push("updated_at = datetime('now')".to_string());
let sql = format!(
"UPDATE calendar_events SET {} WHERE id = ?{idx}",
sets.join(", ")
);
let sql = crate::rustbox_query_db_calendar_004!(sets.join(", "), idx);
values.push(Box::new(id.to_string()));
let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|b| b.as_ref()).collect();
@@ -349,7 +335,10 @@ pub fn update_event(
pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM calendar_events WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_calendar::Q_003,
params![id],
)
.map_err(|e| format!("Failed to delete event: {e}"))?;
Ok(deleted > 0)
}
@@ -358,7 +347,7 @@ pub fn find_event_by_source_uid(
conn: &Connection,
source_uid: &str,
) -> Result<Option<CalendarEvent>, String> {
let sql = format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?1");
let sql = crate::rustbox_query_db_calendar_005!();
let result = conn.query_row(&sql, params![source_uid], row_to_event);
match result {
@@ -368,18 +357,11 @@ pub fn find_event_by_source_uid(
}
}
const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, String> {
conn.execute(
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
[],
)
conn.execute(crate::queries::sqlite::queries::db_calendar::Q_004, [])
.map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?;
let sql = format!("SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1");
let sql = crate::rustbox_query_db_calendar_006!();
conn.query_row(&sql, [], row_to_settings)
.map_err(|e| format!("Failed to get calendar settings: {e}"))
}
@@ -388,10 +370,7 @@ pub fn update_calendar_settings(
conn: &Connection,
data: &UpdateCalendarSettings,
) -> Result<CalendarSettings, String> {
conn.execute(
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
[],
)
conn.execute(crate::queries::sqlite::queries::db_calendar::Q_005, [])
.map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?;
let mut sets = Vec::new();
@@ -436,10 +415,7 @@ pub fn update_calendar_settings(
if !sets.is_empty() {
sets.push("updated_at = datetime('now')".to_string());
let sql = format!(
"UPDATE calendar_settings SET {} WHERE id = 1",
sets.join(", ")
);
let sql = crate::rustbox_query_db_calendar_007!(sets.join(", "));
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
values.iter().map(|b| b.as_ref()).collect();
conn.execute(&sql, params_refs.as_slice())
@@ -451,10 +427,7 @@ pub fn update_calendar_settings(
pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>, String> {
let mut stmt = conn
.prepare(
"SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates ORDER BY is_default DESC, name ASC",
)
.prepare(crate::queries::sqlite::queries::db_calendar::Q_006)
.map_err(|e| format!("Failed to prepare list_meeting_templates: {e}"))?;
let rows = stmt
.query_map([], row_to_template)
@@ -474,21 +447,23 @@ pub fn create_meeting_template(
icon: &str,
) -> Result<MeetingTemplate, String> {
conn.execute(
"INSERT INTO meeting_templates (name, url_template, icon) VALUES (?1, ?2, ?3)",
crate::queries::sqlite::queries::db_calendar::Q_007,
params![name, url_template, icon],
)
.map_err(|e| format!("Failed to create meeting template: {e}"))?;
let id = conn.last_insert_rowid();
let sql = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates WHERE id = ?1";
let sql = crate::queries::sqlite::queries::db_calendar::Q_008;
conn.query_row(sql, params![id], row_to_template)
.map_err(|e| format!("Failed to read created template: {e}"))
}
pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM meeting_templates WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_calendar::Q_009,
params![id],
)
.map_err(|e| format!("Failed to delete meeting template: {e}"))?;
Ok(deleted > 0)
}
+11 -27
View File
@@ -14,7 +14,7 @@ pub struct ContactGroup {
pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), String> {
conn.execute(
"INSERT INTO contact_groups (id, name) VALUES (?1, ?2)",
crate::queries::sqlite::queries::db_contact_groups::Q_001,
params![id, name],
)
.map_err(|e| format!("Failed to create contact group: {e}"))?;
@@ -23,12 +23,7 @@ pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), Strin
pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
let mut stmt = conn
.prepare(
"SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count
FROM contact_groups g
ORDER BY g.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_contact_groups::Q_002)
.map_err(|e| format!("Failed to prepare list_groups: {e}"))?;
let rows = stmt
@@ -53,7 +48,7 @@ pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, String> {
let updated = conn
.execute(
"UPDATE contact_groups SET name = ?1, updated_at = datetime('now') WHERE id = ?2",
crate::queries::sqlite::queries::db_contact_groups::Q_003,
params![name, id],
)
.map_err(|e| format!("Failed to update contact group: {e}"))?;
@@ -62,14 +57,17 @@ pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, Str
pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM contact_groups WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_contact_groups::Q_004,
params![id],
)
.map_err(|e| format!("Failed to delete contact group: {e}"))?;
Ok(deleted > 0)
}
pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?1, ?2)",
crate::queries::sqlite::queries::db_contact_groups::Q_005,
params![group_id, contact_id],
)
.map_err(|e| format!("Failed to add group member: {e}"))?;
@@ -79,7 +77,7 @@ pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result
pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<bool, String> {
let deleted = conn
.execute(
"DELETE FROM contact_group_members WHERE group_id = ?1 AND contact_id = ?2",
crate::queries::sqlite::queries::db_contact_groups::Q_006,
params![group_id, contact_id],
)
.map_err(|e| format!("Failed to remove group member: {e}"))?;
@@ -88,14 +86,7 @@ pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Res
pub fn list_group_members(conn: &Connection, group_id: &str) -> Result<Vec<Contact>, String> {
let mut stmt = conn
.prepare(
"SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite,
c.last_contacted, c.contact_count, c.source, c.created_at, c.updated_at, c.emails_json, c.phones_json, c.addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = c.id) AS has_photo
FROM contacts c
JOIN contact_group_members m ON m.contact_id = c.id
WHERE m.group_id = ?1
ORDER BY c.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_contact_groups::Q_007)
.map_err(|e| format!("Failed to prepare list_group_members: {e}"))?;
let rows = stmt
@@ -140,14 +131,7 @@ pub fn list_contact_groups(
contact_id: &str,
) -> Result<Vec<ContactGroup>, String> {
let mut stmt = conn
.prepare(
"SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count
FROM contact_groups g
JOIN contact_group_members m ON m.group_id = g.id
WHERE m.contact_id = ?1
ORDER BY g.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_contact_groups::Q_008)
.map_err(|e| format!("Failed to prepare list_contact_groups: {e}"))?;
let rows = stmt
+21 -63
View File
@@ -1,3 +1,4 @@
use crate::queries::sqlite::queries::db_contacts::CONTACT_SELECT_COLS;
use rusqlite::{Connection, params};
use serde::Serialize;
use uuid::Uuid;
@@ -58,8 +59,6 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
})
}
const CONTACT_SELECT_COLS: &str = "id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
fn escape_like(term: &str) -> String {
term.replace('\\', "\\\\")
.replace('%', "\\%")
@@ -68,16 +67,7 @@ fn escape_like(term: &str) -> String {
pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> {
conn.execute(
"INSERT INTO contacts
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
ON CONFLICT(id) DO UPDATE SET
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json",
crate::queries::sqlite::queries::db_contacts::Q_001,
params![
contact.id,
contact.email,
@@ -105,7 +95,7 @@ pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String
}
pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, String> {
let sql = format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1");
let sql = crate::rustbox_query_db_contacts_001!();
let result = conn.query_row(&sql, params![id], row_to_contact);
match result {
@@ -116,11 +106,7 @@ pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, Strin
}
pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Contact>, String> {
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))"
);
let sql = crate::rustbox_query_db_contacts_002!();
let result = conn.query_row(&sql, params![email], row_to_contact);
match result {
@@ -140,22 +126,13 @@ pub fn list_contacts(
Some(q) if !q.is_empty() => {
let escaped = escape_like(q);
let pattern = format!("%{escaped}%");
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2 OFFSET ?3"
);
let sql = crate::rustbox_query_db_contacts_003!();
drop(pattern);
(sql, Some(escaped))
}
_ => {
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ?1 OFFSET ?2"
);
let sql = crate::rustbox_query_db_contacts_004!();
(sql, None)
}
};
@@ -193,7 +170,10 @@ pub fn list_contacts(
pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM contacts WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_contacts::Q_002,
params![id],
)
.map_err(|e| format!("Failed to delete contact: {e}"))?;
Ok(deleted > 0)
}
@@ -202,12 +182,7 @@ pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec
let escaped = escape_like(query);
let pattern = format!("%{escaped}%");
let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2"
);
let sql = crate::rustbox_query_db_contacts_005!();
let mut stmt = conn
.prepare(&sql)
@@ -231,18 +206,7 @@ pub fn search_known_addresses(
let escaped = escape_like(query);
let pattern = format!("%{escaped}%");
let sql = r#"
SELECT email, name
FROM known_addresses
WHERE (email LIKE ?1 ESCAPE '\' OR name LIKE ?1 ESCAPE '\')
AND email NOT IN (SELECT email FROM contacts)
AND NOT EXISTS (
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
WHERE lower(alt.value) = lower(known_addresses.email)
)
ORDER BY email ASC
LIMIT ?2
"#;
let sql = crate::queries::sqlite::queries::db_contacts::Q_003;
let mut stmt = conn
.prepare(sql)
@@ -273,8 +237,7 @@ pub fn populate_known_addresses(
) -> Result<(), String> {
if !from_address.is_empty() {
conn.execute(
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''",
crate::queries::sqlite::queries::db_contacts::Q_004,
params![from_address, from_name],
)
.map_err(|e| format!("Failed to insert known address (from): {e}"))?;
@@ -300,8 +263,7 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin
}
let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
conn.execute(
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''",
crate::queries::sqlite::queries::db_contacts::Q_005,
params![email, name],
)
.map_err(|e| format!("Failed to insert known address (json): {e}"))?;
@@ -313,11 +275,7 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin
#[allow(dead_code)]
pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> {
conn.execute(
"UPDATE contacts
SET contact_count = contact_count + 1,
last_contacted = datetime('now'),
updated_at = datetime('now')
WHERE email = ?1",
crate::queries::sqlite::queries::db_contacts::Q_006,
params![email],
)
.map_err(|e| format!("Failed to increment contact count: {e}"))?;
@@ -328,8 +286,7 @@ pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), Str
pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT OR IGNORE INTO contacts (id, email, name, source)
VALUES (?1, ?2, ?3, 'auto')",
crate::queries::sqlite::queries::db_contacts::Q_007,
params![id, email, name],
)
.map_err(|e| format!("Failed to auto-add contact: {e}"))?;
@@ -343,9 +300,10 @@ pub fn set_contact_photo(
data: &[u8],
) -> Result<(), String> {
conn.execute(
"INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=datetime('now')",
crate::queries::sqlite::queries::db_contacts::Q_008,
params![contact_id, content_type, data],
).map_err(|e| format!("Failed to save contact photo: {e}"))?;
)
.map_err(|e| format!("Failed to save contact photo: {e}"))?;
Ok(())
}
@@ -354,7 +312,7 @@ pub fn get_contact_photo(
contact_id: &str,
) -> Result<Option<(String, Vec<u8>)>, String> {
match conn.query_row(
"SELECT content_type, data FROM contact_photos WHERE contact_id = ?1",
crate::queries::sqlite::queries::db_contacts::Q_009,
params![contact_id],
|row| Ok((row.get(0)?, row.get(1)?)),
) {
@@ -366,7 +324,7 @@ pub fn get_contact_photo(
pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result<bool, String> {
conn.execute(
"DELETE FROM contact_photos WHERE contact_id = ?1",
crate::queries::sqlite::queries::db_contacts::Q_010,
params![contact_id],
)
.map(|n| n > 0)
+95 -44
View File
@@ -185,21 +185,65 @@ fn defaults() -> DisplayPreferences {
pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String> {
let result = conn.query_row(
"SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1",
crate::queries::sqlite::queries::db_display_preferences::Q_001,
[],
|row| Ok(DisplayPreferences {
density: row.get(0)?, theme: row.get(1)?, language: row.get(2)?, compose_format: row.get(3)?,
deep_index: row.get::<_, i32>(4)? != 0, animation_mode: row.get(5)?, preview_pane: row.get(6)?,
ui_scale: row.get(7)?, color_preset: row.get(8)?, accent_color: row.get(9)?, background_color: row.get(10)?, panel_color: row.get(11)?, text_color: row.get(12)?, font_family: row.get(13)?,
font_size: row.get(14)?, row_height: row.get(15)?, folder_pane_width: row.get(16)?, message_pane_width: row.get(17)?,
border_radius: row.get(18)?, contrast: row.get(19)?, show_avatars: row.get::<_, i32>(20)? != 0,
show_attachment_icons: row.get::<_, i32>(21)? != 0, show_flags: row.get::<_, i32>(22)? != 0,
reduce_motion: row.get::<_, i32>(23)? != 0, date_format: row.get(24)?, time_format: row.get(25)?,
compose_autosave_ms: row.get(26)?, default_priority: row.get(27)?, request_mdn_default: row.get::<_, i32>(28)? != 0, attachment_reminder: row.get::<_, i32>(29)? != 0,
icon_size: row.get(30)?, toolbar_density: row.get(31)?, sidebar_item_height: row.get(32)?, reader_max_width: row.get(33)?, composer_width: row.get(34)?,
selection_color: row.get(35)?, unread_font_weight: row.get(36)?, unread_badge_style: row.get(37)?, compact_headers: row.get::<_, i32>(38)? != 0, show_borders: row.get::<_, i32>(39)? != 0,
scrollbar_density: row.get(40)?, monochrome: row.get::<_, i32>(41)? != 0, folder_background: row.get(42)?, message_background: row.get(43)?, reader_background: row.get(44)?, compose_font_family: row.get(45)?, reply_position: row.get(46)?, quote_style: row.get(47)?, forward_mode: row.get(48)?, spellcheck_enabled: row.get::<_, i32>(49)? != 0, interface_skin: row.get(50)?, preset_scale_unlocked: row.get::<_, i32>(51)? != 0, updated_at: row.get(52)?,
}),
|row| {
Ok(DisplayPreferences {
density: row.get(0)?,
theme: row.get(1)?,
language: row.get(2)?,
compose_format: row.get(3)?,
deep_index: row.get::<_, i32>(4)? != 0,
animation_mode: row.get(5)?,
preview_pane: row.get(6)?,
ui_scale: row.get(7)?,
color_preset: row.get(8)?,
accent_color: row.get(9)?,
background_color: row.get(10)?,
panel_color: row.get(11)?,
text_color: row.get(12)?,
font_family: row.get(13)?,
font_size: row.get(14)?,
row_height: row.get(15)?,
folder_pane_width: row.get(16)?,
message_pane_width: row.get(17)?,
border_radius: row.get(18)?,
contrast: row.get(19)?,
show_avatars: row.get::<_, i32>(20)? != 0,
show_attachment_icons: row.get::<_, i32>(21)? != 0,
show_flags: row.get::<_, i32>(22)? != 0,
reduce_motion: row.get::<_, i32>(23)? != 0,
date_format: row.get(24)?,
time_format: row.get(25)?,
compose_autosave_ms: row.get(26)?,
default_priority: row.get(27)?,
request_mdn_default: row.get::<_, i32>(28)? != 0,
attachment_reminder: row.get::<_, i32>(29)? != 0,
icon_size: row.get(30)?,
toolbar_density: row.get(31)?,
sidebar_item_height: row.get(32)?,
reader_max_width: row.get(33)?,
composer_width: row.get(34)?,
selection_color: row.get(35)?,
unread_font_weight: row.get(36)?,
unread_badge_style: row.get(37)?,
compact_headers: row.get::<_, i32>(38)? != 0,
show_borders: row.get::<_, i32>(39)? != 0,
scrollbar_density: row.get(40)?,
monochrome: row.get::<_, i32>(41)? != 0,
folder_background: row.get(42)?,
message_background: row.get(43)?,
reader_background: row.get(44)?,
compose_font_family: row.get(45)?,
reply_position: row.get(46)?,
quote_style: row.get(47)?,
forward_mode: row.get(48)?,
spellcheck_enabled: row.get::<_, i32>(49)? != 0,
interface_skin: row.get(50)?,
preset_scale_unlocked: row.get::<_, i32>(51)? != 0,
updated_at: row.get(52)?,
})
},
);
match result {
Ok(value) => Ok(value),
@@ -231,7 +275,7 @@ pub fn update_preferences(
data: &UpdateDisplayPreferences,
) -> Result<DisplayPreferences, String> {
conn.execute(
"INSERT OR IGNORE INTO display_preferences (id) VALUES (1)",
crate::queries::sqlite::queries::db_display_preferences::Q_002,
[],
)
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
@@ -362,6 +406,12 @@ pub fn update_preferences(
"yahoo",
"hey",
"nord",
"github",
"gitlab",
"discord",
"slack",
"reddit",
"notion",
],
)?;
}
@@ -386,58 +436,57 @@ pub fn update_preferences(
if let Some(v) = data.sidebar_item_height {
range("sidebar_item_height", v, 24, 48)?;
}
if let Some(v) = data.reader_max_width {
if v != 0 {
if let Some(v) = data.reader_max_width
&& v != 0
{
range("reader_max_width", v, 560, 1400)?;
}
}
if let Some(v) = data.composer_width {
range("composer_width", v, 640, 1400)?;
}
if let Some(v) = data.unread_font_weight {
range("unread_font_weight", v, 500, 800)?;
}
if let Some(ref v) = data.accent_color {
if !valid_hex(v) {
if let Some(ref v) = data.accent_color
&& !valid_hex(v)
{
return Err(format!("Invalid accent_color: {v}"));
}
}
if let Some(ref v) = data.background_color {
if !valid_hex(v) {
if let Some(ref v) = data.background_color
&& !valid_hex(v)
{
return Err(format!("Invalid background_color: {v}"));
}
}
if let Some(ref v) = data.panel_color {
if !valid_hex(v) {
if let Some(ref v) = data.panel_color
&& !valid_hex(v)
{
return Err(format!("Invalid panel_color: {v}"));
}
}
if let Some(ref v) = data.text_color {
if !valid_hex(v) {
if let Some(ref v) = data.text_color
&& !valid_hex(v)
{
return Err(format!("Invalid text_color: {v}"));
}
}
if let Some(ref v) = data.selection_color {
if !valid_hex(v) {
if let Some(ref v) = data.selection_color
&& !valid_hex(v)
{
return Err(format!("Invalid selection_color: {v}"));
}
}
for (name, value) in [
("folder_background", data.folder_background.as_deref()),
("message_background", data.message_background.as_deref()),
("reader_background", data.reader_background.as_deref()),
] {
if let Some(v) = value {
if !v.is_empty() && !valid_hex(v) {
if let Some(v) = value
&& !v.is_empty()
&& !valid_hex(v)
{
return Err(format!("Invalid {name}: {v}"));
}
}
}
if let Some(ref mode) = data.animation_mode {
if let Some(v) = mode {
if let Some(Some(v)) = data.animation_mode.as_ref() {
enum_value("animation_mode", v, &["rich", "medium", "subtle", "off"])?;
}
}
set_text!(density, "density");
set_text!(theme, "theme");
@@ -500,11 +549,7 @@ pub fn update_preferences(
return get_preferences(conn);
}
sets.push("updated_at = datetime('now')".to_string());
let sql = format!(
"UPDATE display_preferences SET {} WHERE id = ?{}",
sets.join(", "),
idx
);
let sql = crate::rustbox_query_db_display_preferences_001!(sets.join(", "), idx);
values.push(Box::new(1_i32));
let refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect();
conn.execute(&sql, refs.as_slice())
@@ -558,6 +603,12 @@ mod tests {
"yahoo",
"hey",
"nord",
"github",
"gitlab",
"discord",
"slack",
"reddit",
"notion",
] {
let conn = open_test_db();
let update: UpdateDisplayPreferences =
+14 -34
View File
@@ -72,25 +72,7 @@ pub fn upsert_draft(
) -> Result<(), String> {
validate_draft_id(id)?;
conn.execute(
"INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
to_addresses = excluded.to_addresses,
cc_addresses = excluded.cc_addresses,
bcc_addresses = excluded.bcc_addresses,
subject = excluded.subject,
text_body = excluded.text_body,
html_body = excluded.html_body,
in_reply_to = excluded.in_reply_to,
references_header = excluded.references_header,
request_mdn = excluded.request_mdn,
priority = excluded.priority,
compose_format = excluded.compose_format,
pgp_mode = excluded.pgp_mode,
pgp_inline = excluded.pgp_inline,
pgp_attach_public_key = excluded.pgp_attach_public_key,
pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys,
updated_at = datetime('now')",
crate::queries::sqlite::queries::db_drafts::Q_001,
params![
id,
to_addresses,
@@ -116,8 +98,7 @@ pub fn upsert_draft(
pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
let result = conn.query_row(
"SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts WHERE id = ?1",
crate::queries::sqlite::queries::db_drafts::Q_002,
params![id],
|row| {
Ok(Draft {
@@ -155,10 +136,7 @@ pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
let mut stmt = conn
.prepare(
"SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts ORDER BY updated_at DESC",
)
.prepare(crate::queries::sqlite::queries::db_drafts::Q_003)
.map_err(|e| format!("Failed to prepare list_drafts: {e}"))?;
let rows = stmt
@@ -205,7 +183,7 @@ pub fn set_remote_location(
) -> Result<(), String> {
let updated = conn
.execute(
"UPDATE drafts SET remote_folder = ?2, remote_uid = ?3, remote_uid_validity = ?4 WHERE id = ?1",
crate::queries::sqlite::queries::db_drafts::Q_004,
params![id, folder, uid, uid_validity],
)
.map_err(|e| format!("Failed to update remote draft location: {e}"))?;
@@ -217,7 +195,10 @@ pub fn set_remote_location(
pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM drafts WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_drafts::Q_005,
params![id],
)
.map_err(|e| format!("Failed to delete draft: {e}"))?;
Ok(deleted > 0)
}
@@ -232,8 +213,7 @@ pub fn add_draft_attachment(
file_path: &str,
) -> Result<(), String> {
conn.execute(
"INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
crate::queries::sqlite::queries::db_drafts::Q_006,
params![id, draft_id, filename, content_type, size, file_path],
)
.map_err(|e| format!("Failed to add draft attachment: {e}"))?;
@@ -245,10 +225,7 @@ pub fn get_draft_attachments(
draft_id: &str,
) -> Result<Vec<DraftAttachment>, String> {
let mut stmt = conn
.prepare(
"SELECT id, draft_id, filename, content_type, size, file_path, created_at
FROM draft_attachments WHERE draft_id = ?1 ORDER BY created_at ASC",
)
.prepare(crate::queries::sqlite::queries::db_drafts::Q_007)
.map_err(|e| format!("Failed to prepare get_draft_attachments: {e}"))?;
let rows = stmt
@@ -274,7 +251,10 @@ pub fn get_draft_attachments(
pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM draft_attachments WHERE id = ?1", params![id])
.execute(
crate::queries::sqlite::queries::db_drafts::Q_008,
params![id],
)
.map_err(|e| format!("Failed to delete draft attachment: {e}"))?;
Ok(deleted > 0)
}
+27 -12
View File
@@ -14,7 +14,7 @@ pub struct UpdateFolderPreferences {
fn get_override(conn: &Connection, folder: &str) -> Result<Option<bool>, String> {
conn.query_row(
"SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?1",
crate::queries::sqlite::queries::db_folder_preferences::Q_001,
[folder],
|row| row.get::<_, Option<i32>>(0),
)
@@ -29,7 +29,7 @@ pub fn effective_flagged_on_top(conn: &Connection, folder: &str) -> Result<bool,
}
let global = conn
.query_row(
"SELECT flagged_on_top FROM mail_preferences WHERE id = 1",
crate::queries::sqlite::queries::db_folder_preferences::Q_002,
[],
|row| row.get::<_, i32>(0),
)
@@ -40,7 +40,7 @@ pub fn effective_flagged_on_top(conn: &Connection, folder: &str) -> Result<bool,
pub fn get_preferences(conn: &Connection, folder: &str) -> Result<FolderPreferences, String> {
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
crate::queries::sqlite::queries::db_folder_preferences::Q_003,
[folder],
|row| row.get(0),
)
@@ -51,7 +51,7 @@ pub fn get_preferences(conn: &Connection, folder: &str) -> Result<FolderPreferen
let flagged_on_top = get_override(conn, folder)?;
let effective_flagged_on_top = flagged_on_top.unwrap_or(
conn.query_row(
"SELECT flagged_on_top FROM mail_preferences WHERE id = 1",
crate::queries::sqlite::queries::db_folder_preferences::Q_004,
[],
|row| row.get::<_, i32>(0),
)
@@ -71,7 +71,7 @@ pub fn update_preferences(
) -> Result<FolderPreferences, String> {
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
crate::queries::sqlite::queries::db_folder_preferences::Q_005,
[folder],
|row| row.get(0),
)
@@ -83,13 +83,16 @@ pub fn update_preferences(
match data.flagged_on_top {
Some(value) => {
conn.execute(
"INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = datetime('now')",
crate::queries::sqlite::queries::db_folder_preferences::Q_006,
params![folder, value as i32],
)
.map_err(|error| format!("Failed to update folder preferences: {error}"))?;
}
None => {
conn.execute("DELETE FROM folder_preferences WHERE folder_name = ?1", [folder])
conn.execute(
crate::queries::sqlite::queries::db_folder_preferences::Q_007,
[folder],
)
.map_err(|error| format!("Failed to clear folder preferences: {error}"))?;
}
}
@@ -100,14 +103,18 @@ pub fn update_preferences(
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{folders, mail_preferences};
use crate::db::pool::open_test_db;
use crate::db::{folders, mail_preferences};
#[test]
fn folder_override_inherits_and_overrides_global_value() {
let conn = open_test_db();
folders::insert_folder_if_new(&conn, "INBOX", Some("/"), "").unwrap();
assert!(!get_preferences(&conn, "INBOX").unwrap().effective_flagged_on_top);
assert!(
!get_preferences(&conn, "INBOX")
.unwrap()
.effective_flagged_on_top
);
mail_preferences::update_preferences(
&conn,
@@ -125,12 +132,18 @@ mod tests {
},
)
.unwrap();
assert!(get_preferences(&conn, "INBOX").unwrap().effective_flagged_on_top);
assert!(
get_preferences(&conn, "INBOX")
.unwrap()
.effective_flagged_on_top
);
let overridden = update_preferences(
&conn,
"INBOX",
&UpdateFolderPreferences { flagged_on_top: Some(false) },
&UpdateFolderPreferences {
flagged_on_top: Some(false),
},
)
.unwrap();
assert_eq!(overridden.flagged_on_top, Some(false));
@@ -139,7 +152,9 @@ mod tests {
let inherited = update_preferences(
&conn,
"INBOX",
&UpdateFolderPreferences { flagged_on_top: None },
&UpdateFolderPreferences {
flagged_on_top: None,
},
)
.unwrap();
assert_eq!(inherited.flagged_on_top, None);
+38 -59
View File
@@ -28,10 +28,7 @@ pub fn upsert_folder(
highest_modseq: u64,
) -> Result<(), String> {
conn.execute(
"INSERT OR REPLACE INTO folders
(name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))",
crate::queries::sqlite::queries::db_folders::Q_001,
params![
folder_name,
delimiter,
@@ -55,9 +52,7 @@ pub fn insert_folder_if_new(
flags_csv: &str,
) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO folders
(name, delimiter, flags, is_subscribed, updated_at)
VALUES (?1, ?2, ?3, 1, datetime('now'))",
crate::queries::sqlite::queries::db_folders::Q_002,
params![folder_name, delimiter, flags_csv],
)
.map_err(|e| format!("Failed to insert folder: {e}"))?;
@@ -66,12 +61,7 @@ pub fn insert_folder_if_new(
pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
let mut stmt = conn
.prepare(
"SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
ORDER BY name",
)
.prepare(crate::queries::sqlite::queries::db_folders::Q_003)
.map_err(|e| format!("Failed to prepare get_all_folders: {e}"))?;
let rows = stmt
@@ -102,16 +92,13 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
pub fn remove_stale_folders(conn: &Connection, current_names: &[String]) -> Result<usize, String> {
if current_names.is_empty() {
let deleted = conn
.execute("DELETE FROM folders", [])
.execute(crate::queries::sqlite::queries::db_folders::Q_004, [])
.map_err(|e| format!("Failed to delete all folders: {e}"))?;
return Ok(deleted);
}
let placeholders: Vec<String> = (1..=current_names.len()).map(|i| format!("?{i}")).collect();
let sql = format!(
"DELETE FROM folders WHERE name NOT IN ({})",
placeholders.join(", ")
);
let sql = crate::rustbox_query_db_folders_001!(placeholders.join(", "));
let params: Vec<&dyn rusqlite::types::ToSql> = current_names
.iter()
@@ -126,7 +113,7 @@ pub fn remove_stale_folders(conn: &Connection, current_names: &[String]) -> Resu
}
pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
conn.execute("UPDATE folders SET updated_at = datetime('now')", [])
conn.execute(crate::queries::sqlite::queries::db_folders::Q_005, [])
.map_err(|e| format!("Failed to touch folders: {e}"))?;
Ok(())
}
@@ -138,8 +125,7 @@ pub fn update_folder_status(
total_count: u32,
) -> Result<(), String> {
conn.execute(
"UPDATE folders SET uid_validity = ?1, total_count = ?2, messages_updated_at = datetime('now')
WHERE name = ?3",
crate::queries::sqlite::queries::db_folders::Q_006,
params![uid_validity, total_count, name],
)
.map_err(|e| format!("Failed to update folder status: {e}"))?;
@@ -154,8 +140,7 @@ pub fn update_folder_sync_status(
highest_modseq: u64,
) -> Result<(), String> {
conn.execute(
"UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3
WHERE name = ?4",
crate::queries::sqlite::queries::db_folders::Q_007,
params![uid_validity, total_count, highest_modseq as i64, name],
)
.map_err(|e| format!("Failed to update folder sync status: {e}"))?;
@@ -165,14 +150,14 @@ pub fn update_folder_sync_status(
pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> {
let unread: u32 = conn
.query_row(
"SELECT COUNT(*) FROM messages WHERE folder = ?1 AND flags NOT LIKE '%\\Seen%'",
crate::queries::sqlite::queries::db_folders::Q_008,
params![folder_name],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count unread messages: {e}"))?;
conn.execute(
"UPDATE folders SET unread_count = ?1 WHERE name = ?2",
crate::queries::sqlite::queries::db_folders::Q_009,
params![unread, folder_name],
)
.map_err(|e| format!("Failed to update unread_count: {e}"))?;
@@ -182,7 +167,7 @@ pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(),
pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> {
conn.execute(
"UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2",
crate::queries::sqlite::queries::db_folders::Q_010,
params![delta, folder_name],
)
.map_err(|e| format!("Failed to adjust unread_count: {e}"))?;
@@ -192,10 +177,7 @@ pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) ->
pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result<bool, String> {
let fresh: bool = conn
.query_row(
"SELECT EXISTS(
SELECT 1 FROM folders
WHERE updated_at > datetime('now', ?1)
)",
crate::queries::sqlite::queries::db_folders::Q_011,
params![format!("-{max_age_secs} seconds")],
|row| row.get(0),
)
@@ -210,10 +192,7 @@ pub fn is_folder_fresh(
) -> Result<bool, String> {
let fresh: bool = conn
.query_row(
"SELECT EXISTS(
SELECT 1 FROM folders
WHERE name = ?1 AND messages_updated_at > datetime('now', ?2)
)",
crate::queries::sqlite::queries::db_folders::Q_012,
params![folder_name, format!("-{max_age_secs} seconds")],
|row| row.get(0),
)
@@ -227,7 +206,7 @@ pub fn is_folder_messages_invalidated(
) -> Result<bool, String> {
let invalidated: bool = conn
.query_row(
"SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1",
crate::queries::sqlite::queries::db_folders::Q_013,
params![folder_name],
|row| row.get(0),
)
@@ -237,7 +216,7 @@ pub fn is_folder_messages_invalidated(
pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute(
"UPDATE folders SET messages_updated_at = NULL WHERE name = ?1",
crate::queries::sqlite::queries::db_folders::Q_014,
params![folder_name],
)
.map_err(|e| format!("Failed to invalidate folder freshness: {e}"))?;
@@ -246,10 +225,7 @@ pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Resu
pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>, String> {
let result = conn.query_row(
"SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
WHERE name = ?1",
crate::queries::sqlite::queries::db_folders::Q_015,
params![name],
|row| {
let is_subscribed_int: i32 = row.get(4)?;
@@ -277,11 +253,14 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute(
"DELETE FROM messages WHERE folder = ?1",
crate::queries::sqlite::queries::db_folders::Q_016,
params![folder_name],
)
.map_err(|e| format!("Failed to delete folder messages: {e}"))?;
conn.execute("DELETE FROM folders WHERE name = ?1", params![folder_name])
conn.execute(
crate::queries::sqlite::queries::db_folders::Q_017,
params![folder_name],
)
.map_err(|e| format!("Failed to delete folder: {e}"))?;
Ok(())
}
@@ -294,40 +273,43 @@ pub fn rename_folder_in_cache(
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Failed to start folder rename transaction: {e}"))?;
tx.execute_batch("PRAGMA defer_foreign_keys = ON;")
tx.execute_batch(crate::queries::sqlite::queries::db_folders::Q_018)
.map_err(|e| format!("Failed to defer folder rename foreign keys: {e}"))?;
let inserted = tx.execute(
"INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at)
SELECT ?1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, datetime('now'), messages_updated_at
FROM folders WHERE name = ?2",
let inserted = tx
.execute(
crate::queries::sqlite::queries::db_folders::Q_019,
params![new_name, old_name],
).map_err(|e| format!("Failed to create renamed folder cache entry: {e}"))?;
)
.map_err(|e| format!("Failed to create renamed folder cache entry: {e}"))?;
if inserted == 0 {
return Err(format!("Folder not found in cache: {old_name}"));
}
tx.execute(
"UPDATE messages SET folder = ?1 WHERE folder = ?2",
crate::queries::sqlite::queries::db_folders::Q_020,
params![new_name, old_name],
)
.map_err(|e| format!("Failed to rename messages folder: {e}"))?;
tx.execute(
"UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2",
crate::queries::sqlite::queries::db_folders::Q_021,
params![new_name, old_name],
)
.map_err(|e| format!("Failed to rename message tag folder references: {e}"))?;
tx.execute(
"UPDATE calendar_events SET source_message_folder = ?1 WHERE source_message_folder = ?2",
crate::queries::sqlite::queries::db_folders::Q_022,
params![new_name, old_name],
)
.ok();
tx.execute(
"UPDATE folders SET parent = ?1 WHERE parent = ?2",
crate::queries::sqlite::queries::db_folders::Q_023,
params![new_name, old_name],
)
.map_err(|e| format!("Failed to update child folder parent: {e}"))?;
tx.execute("DELETE FROM folders WHERE name = ?1", params![old_name])
tx.execute(
crate::queries::sqlite::queries::db_folders::Q_024,
params![old_name],
)
.map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?;
tx.commit()
.map_err(|e| format!("Failed to commit folder rename: {e}"))?;
@@ -339,7 +321,7 @@ pub fn find_folder_by_attribute(
attribute: &str,
) -> Result<Option<String>, String> {
conn.query_row(
"SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1",
crate::queries::sqlite::queries::db_folders::Q_025,
[attribute],
|row| row.get(0),
)
@@ -460,17 +442,14 @@ mod tests {
fn sync_metadata_preserves_message_sync_timestamp() {
let conn = open_test_db();
insert_folder_if_new(&conn, "Sent", None, "\\Sent").unwrap();
conn.execute(
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'",
[],
)
conn.execute(crate::queries::sqlite::queries::db_folders::Q_026, [])
.unwrap();
update_folder_sync_status(&conn, "Sent", 1, 5, 42).unwrap();
let timestamp: String = conn
.query_row(
"SELECT messages_updated_at FROM folders WHERE name = 'Sent'",
crate::queries::sqlite::queries::db_folders::Q_027,
[],
|row| row.get(0),
)
+2 -6
View File
@@ -9,7 +9,7 @@ impl HeaderSync {
pub(crate) fn capture(conn: &Connection) -> Result<Self, String> {
let generation = conn
.query_row(
"SELECT value FROM user_meta WHERE key = 'header_sync_generation'",
crate::queries::sqlite::queries::db_header_sync::Q_001,
[],
|row| row.get::<_, String>(0),
)
@@ -32,11 +32,7 @@ impl HeaderSync {
}
pub(crate) fn invalidate(conn: &Transaction<'_>) -> Result<(), String> {
conn.execute(
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1",
[],
)
conn.execute(crate::queries::sqlite::queries::db_header_sync::Q_002, [])
.map_err(|e| e.to_string())?;
Ok(())
}
+193 -61
View File
@@ -1,4 +1,5 @@
use rusqlite::{params, Connection};
use crate::queries::sqlite::queries::db_identities::IDENTITY_SELECT_COLS;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
@@ -26,22 +27,37 @@ pub struct Identity {
#[derive(Debug, Deserialize)]
pub struct CreateIdentity {
#[serde(default)] pub display_name: String,
#[serde(default)]
pub display_name: String,
pub email: String,
#[serde(default)] pub signature_html: String,
#[serde(default)] pub is_default: bool,
#[serde(default)] pub reply_to: String,
#[serde(default)] pub bcc: String,
#[serde(default)] pub organization: String,
#[serde(default)] pub signature_text: String,
#[serde(default = "default_true")] pub signature_new: bool,
#[serde(default = "default_true")] pub signature_reply: bool,
#[serde(default = "default_true")] pub signature_forward: bool,
#[serde(default = "default_signature_position")] pub signature_position: String,
#[serde(default)] pub pgp_fingerprint: Option<String>,
#[serde(default)] pub pgp_sign_default: bool,
#[serde(default)] pub pgp_encrypt_default: bool,
#[serde(default)] pub pgp_inline_default: bool,
#[serde(default)]
pub signature_html: String,
#[serde(default)]
pub is_default: bool,
#[serde(default)]
pub reply_to: String,
#[serde(default)]
pub bcc: String,
#[serde(default)]
pub organization: String,
#[serde(default)]
pub signature_text: String,
#[serde(default = "default_true")]
pub signature_new: bool,
#[serde(default = "default_true")]
pub signature_reply: bool,
#[serde(default = "default_true")]
pub signature_forward: bool,
#[serde(default = "default_signature_position")]
pub signature_position: String,
#[serde(default)]
pub pgp_fingerprint: Option<String>,
#[serde(default)]
pub pgp_sign_default: bool,
#[serde(default)]
pub pgp_encrypt_default: bool,
#[serde(default)]
pub pgp_inline_default: bool,
}
#[derive(Debug, Deserialize)]
@@ -64,11 +80,19 @@ pub struct UpdateIdentity {
pub pgp_inline_default: Option<bool>,
}
fn default_true() -> bool { true }
fn default_signature_position() -> String { "above".to_string() }
fn default_true() -> bool {
true
}
fn default_signature_position() -> String {
"above".to_string()
}
fn validate_signature_position(value: &str) -> Result<(), String> {
if matches!(value, "above" | "below") { Ok(()) } else { Err(format!("Invalid signature_position: {value}")) }
if matches!(value, "above" | "below") {
Ok(())
} else {
Err(format!("Invalid signature_position: {value}"))
}
}
fn row_to_identity(row: &rusqlite::Row<'_>) -> rusqlite::Result<Identity> {
@@ -95,17 +119,20 @@ fn row_to_identity(row: &rusqlite::Row<'_>) -> rusqlite::Result<Identity> {
})
}
const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub fn list_identities(conn: &Connection) -> Result<Vec<Identity>, String> {
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC");
let mut stmt = conn.prepare(&sql).map_err(|e| format!("Failed to prepare list_identities: {e}"))?;
let rows = stmt.query_map([], row_to_identity).map_err(|e| format!("Failed to query identities: {e}"))?;
rows.map(|row| row.map_err(|e| format!("Failed to read identity row: {e}"))).collect()
let sql = crate::rustbox_query_db_identities_001!();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare list_identities: {e}"))?;
let rows = stmt
.query_map([], row_to_identity)
.map_err(|e| format!("Failed to query identities: {e}"))?;
rows.map(|row| row.map_err(|e| format!("Failed to read identity row: {e}")))
.collect()
}
pub fn get_identity(conn: &Connection, id: i64) -> Result<Option<Identity>, String> {
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1");
let sql = crate::rustbox_query_db_identities_002!();
match conn.query_row(&sql, params![id], row_to_identity) {
Ok(identity) => Ok(Some(identity)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@@ -115,7 +142,7 @@ pub fn get_identity(conn: &Connection, id: i64) -> Result<Option<Identity>, Stri
#[allow(dead_code)]
pub fn get_default_identity(conn: &Connection) -> Result<Option<Identity>, String> {
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1");
let sql = crate::rustbox_query_db_identities_003!();
match conn.query_row(&sql, [], row_to_identity) {
Ok(identity) => Ok(Some(identity)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@@ -125,49 +152,127 @@ pub fn get_default_identity(conn: &Connection) -> Result<Option<Identity>, Strin
pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result<Identity, String> {
validate_signature_position(&data.signature_position)?;
if data.is_default { clear_default(conn)?; }
if data.is_default {
clear_default(conn)?;
}
conn.execute(
"INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)",
params![data.display_name, data.email, data.signature_html, data.is_default as i32, data.reply_to, data.bcc, data.organization, data.signature_text, data.signature_new as i32, data.signature_reply as i32, data.signature_forward as i32, data.signature_position, data.pgp_fingerprint, data.pgp_sign_default as i32, data.pgp_encrypt_default as i32, data.pgp_inline_default as i32],
).map_err(|e| format!("Failed to create identity: {e}"))?;
crate::queries::sqlite::queries::db_identities::Q_001,
params![
data.display_name,
data.email,
data.signature_html,
data.is_default as i32,
data.reply_to,
data.bcc,
data.organization,
data.signature_text,
data.signature_new as i32,
data.signature_reply as i32,
data.signature_forward as i32,
data.signature_position,
data.pgp_fingerprint,
data.pgp_sign_default as i32,
data.pgp_encrypt_default as i32,
data.pgp_inline_default as i32
],
)
.map_err(|e| format!("Failed to create identity: {e}"))?;
let id = conn.last_insert_rowid();
get_identity(conn, id)?.ok_or_else(|| "Failed to read back created identity".to_string())
}
pub fn update_identity(conn: &Connection, id: i64, data: &UpdateIdentity) -> Result<Option<Identity>, String> {
if get_identity(conn, id)?.is_none() { return Ok(None); }
if data.is_default == Some(true) { clear_default(conn)?; }
if let Some(value) = data.signature_position.as_deref() { validate_signature_position(value)?; }
pub fn update_identity(
conn: &Connection,
id: i64,
data: &UpdateIdentity,
) -> Result<Option<Identity>, String> {
if get_identity(conn, id)?.is_none() {
return Ok(None);
}
if data.is_default == Some(true) {
clear_default(conn)?;
}
if let Some(value) = data.signature_position.as_deref() {
validate_signature_position(value)?;
}
let mut sets = Vec::new();
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
let mut idx = 1usize;
macro_rules! text { ($field:ident,$column:literal) => { if let Some(ref value)=data.$field { sets.push(format!(concat!($column," = ?{}"),idx)); values.push(Box::new(value.clone())); idx += 1; } }; }
macro_rules! boolean { ($field:ident,$column:literal) => { if let Some(value)=data.$field { sets.push(format!(concat!($column," = ?{}"),idx)); values.push(Box::new(value as i32)); idx += 1; } }; }
text!(display_name,"display_name"); text!(email,"email"); text!(signature_html,"signature_html"); boolean!(is_default,"is_default");
text!(reply_to,"reply_to"); text!(bcc,"bcc"); text!(organization,"organization"); text!(signature_text,"signature_text");
boolean!(signature_new,"signature_new"); boolean!(signature_reply,"signature_reply"); boolean!(signature_forward,"signature_forward"); text!(signature_position,"signature_position");
if let Some(ref value) = data.pgp_fingerprint { sets.push(format!("pgp_fingerprint = ?{}", idx)); values.push(Box::new(value.clone())); idx += 1; }
boolean!(pgp_sign_default,"pgp_sign_default"); boolean!(pgp_encrypt_default,"pgp_encrypt_default"); boolean!(pgp_inline_default,"pgp_inline_default");
if sets.is_empty() { return get_identity(conn,id); }
macro_rules! text {
($field:ident,$column:literal) => {
if let Some(ref value) = data.$field {
sets.push(format!(concat!($column, " = ?{}"), idx));
values.push(Box::new(value.clone()));
idx += 1;
}
};
}
macro_rules! boolean {
($field:ident,$column:literal) => {
if let Some(value) = data.$field {
sets.push(format!(concat!($column, " = ?{}"), idx));
values.push(Box::new(value as i32));
idx += 1;
}
};
}
text!(display_name, "display_name");
text!(email, "email");
text!(signature_html, "signature_html");
boolean!(is_default, "is_default");
text!(reply_to, "reply_to");
text!(bcc, "bcc");
text!(organization, "organization");
text!(signature_text, "signature_text");
boolean!(signature_new, "signature_new");
boolean!(signature_reply, "signature_reply");
boolean!(signature_forward, "signature_forward");
text!(signature_position, "signature_position");
if let Some(ref value) = data.pgp_fingerprint {
sets.push(format!("pgp_fingerprint = ?{}", idx));
values.push(Box::new(value.clone()));
idx += 1;
}
boolean!(pgp_sign_default, "pgp_sign_default");
boolean!(pgp_encrypt_default, "pgp_encrypt_default");
boolean!(pgp_inline_default, "pgp_inline_default");
if sets.is_empty() {
return get_identity(conn, id);
}
sets.push("updated_at = datetime('now')".to_string());
let sql = format!("UPDATE identities SET {} WHERE id = ?{}", sets.join(", "), idx);
let sql = crate::rustbox_query_db_identities_004!(sets.join(", "), idx);
values.push(Box::new(id));
let refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|value| value.as_ref()).collect();
conn.execute(&sql, refs.as_slice()).map_err(|e| format!("Failed to update identity: {e}"))?;
get_identity(conn,id)
let refs: Vec<&dyn rusqlite::types::ToSql> =
values.iter().map(|value| value.as_ref()).collect();
conn.execute(&sql, refs.as_slice())
.map_err(|e| format!("Failed to update identity: {e}"))?;
get_identity(conn, id)
}
pub fn delete_identity(conn: &Connection, id: i64) -> Result<bool, String> {
Ok(conn.execute("DELETE FROM identities WHERE id = ?1", params![id]).map_err(|e| format!("Failed to delete identity: {e}"))? > 0)
Ok(conn
.execute(
crate::queries::sqlite::queries::db_identities::Q_002,
params![id],
)
.map_err(|e| format!("Failed to delete identity: {e}"))?
> 0)
}
pub fn has_identities(conn: &Connection) -> Result<bool, String> {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM identities", [], |row| row.get(0)).map_err(|e| format!("Failed to count identities: {e}"))?;
let count: i64 = conn
.query_row(
crate::queries::sqlite::queries::db_identities::Q_003,
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count identities: {e}"))?;
Ok(count > 0)
}
fn clear_default(conn: &Connection) -> Result<(), String> {
conn.execute("UPDATE identities SET is_default = 0, updated_at = datetime('now') WHERE is_default = 1", []).map_err(|e| format!("Failed to clear default identity: {e}"))?;
conn.execute(crate::queries::sqlite::queries::db_identities::Q_004, [])
.map_err(|e| format!("Failed to clear default identity: {e}"))?;
Ok(())
}
@@ -177,28 +282,55 @@ mod tests {
use crate::db::pool::open_test_db;
fn sample(email: &str, is_default: bool) -> CreateIdentity {
CreateIdentity { display_name:"Alice".into(), email:email.into(), signature_html:"<p>Best</p>".into(), is_default, reply_to:String::new(), bcc:String::new(), organization:String::new(), signature_text:"Best".into(), signature_new:true, signature_reply:true, signature_forward:true, signature_position:"above".into(), pgp_fingerprint:None, pgp_sign_default:false, pgp_encrypt_default:false, pgp_inline_default:false }
CreateIdentity {
display_name: "Alice".into(),
email: email.into(),
signature_html: "<p>Best</p>".into(),
is_default,
reply_to: String::new(),
bcc: String::new(),
organization: String::new(),
signature_text: "Best".into(),
signature_new: true,
signature_reply: true,
signature_forward: true,
signature_position: "above".into(),
pgp_fingerprint: None,
pgp_sign_default: false,
pgp_encrypt_default: false,
pgp_inline_default: false,
}
}
#[test]
fn create_and_extended_options_roundtrip() {
let conn=open_test_db();
let mut value=sample("alice@example.com", true);
value.reply_to="reply@example.com".into(); value.bcc="archive@example.com".into(); value.organization="Example".into(); value.signature_position="below".into();
let identity=create_identity(&conn,&value).unwrap();
assert_eq!(identity.reply_to,"reply@example.com"); assert_eq!(identity.bcc,"archive@example.com"); assert_eq!(identity.organization,"Example"); assert_eq!(identity.signature_position,"below");
let conn = open_test_db();
let mut value = sample("alice@example.com", true);
value.reply_to = "reply@example.com".into();
value.bcc = "archive@example.com".into();
value.organization = "Example".into();
value.signature_position = "below".into();
let identity = create_identity(&conn, &value).unwrap();
assert_eq!(identity.reply_to, "reply@example.com");
assert_eq!(identity.bcc, "archive@example.com");
assert_eq!(identity.organization, "Example");
assert_eq!(identity.signature_position, "below");
}
#[test]
fn only_one_default() {
let conn=open_test_db();
let first=create_identity(&conn,&sample("a@example.com",true)).unwrap();
let second=create_identity(&conn,&sample("b@example.com",true)).unwrap();
assert!(second.is_default); assert!(!get_identity(&conn,first.id).unwrap().unwrap().is_default);
let conn = open_test_db();
let first = create_identity(&conn, &sample("a@example.com", true)).unwrap();
let second = create_identity(&conn, &sample("b@example.com", true)).unwrap();
assert!(second.is_default);
assert!(!get_identity(&conn, first.id).unwrap().unwrap().is_default);
}
#[test]
fn rejects_invalid_signature_position() {
let conn=open_test_db(); let mut value=sample("a@example.com",false); value.signature_position="side".into(); assert!(create_identity(&conn,&value).is_err());
let conn = open_test_db();
let mut value = sample("a@example.com", false);
value.signature_position = "side".into();
assert!(create_identity(&conn, &value).is_err());
}
}
+63 -29
View File
@@ -32,7 +32,7 @@ pub struct UpdateMailPreferences {
pub fn get_preferences(conn: &Connection) -> Result<MailPreferences, String> {
conn.query_row(
"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",
crate::queries::sqlite::queries::db_mail_preferences::Q_001,
[],
|row| {
Ok(MailPreferences {
@@ -57,58 +57,92 @@ pub fn update_preferences(
conn: &Connection,
data: &UpdateMailPreferences,
) -> Result<MailPreferences, String> {
conn.execute("INSERT OR IGNORE INTO mail_preferences (id) VALUES (1)", [])
conn.execute(
crate::queries::sqlite::queries::db_mail_preferences::Q_002,
[],
)
.map_err(|error| format!("Failed to initialize mail preferences: {error}"))?;
if let Some(value) = data.messages_per_page {
if !(20..=200).contains(&value) {
if let Some(value) = data.messages_per_page
&& !(20..=200).contains(&value)
{
return Err("messages_per_page must be between 20 and 200".to_string());
}
}
if let Some(value) = data.mark_read_delay_ms {
if value > 30_000 {
if let Some(value) = data.mark_read_delay_ms
&& value > 30_000
{
return Err("mark_read_delay_ms must be between 0 and 30000".to_string());
}
}
if let Some(ref value) = data.remote_images {
if !matches!(value.as_str(), "never" | "contacts" | "ask" | "always") {
if let Some(ref value) = data.remote_images
&& !matches!(value.as_str(), "never" | "contacts" | "ask" | "always")
{
return Err("remote_images must be never, contacts, ask or always".to_string());
}
}
if let Some(ref value) = data.preferred_body {
if !matches!(value.as_str(), "auto" | "html" | "text") {
if let Some(ref value) = data.preferred_body
&& !matches!(value.as_str(), "auto" | "html" | "text")
{
return Err("preferred_body must be auto, html or text".to_string());
}
}
if let Some(ref value) = data.thread_mode {
if !matches!(value.as_str(), "threaded" | "flat") {
if let Some(ref value) = data.thread_mode
&& !matches!(value.as_str(), "threaded" | "flat")
{
return Err("thread_mode must be threaded or flat".to_string());
}
}
if let Some(ref value) = data.default_sort {
if !matches!(
if let Some(ref value) = data.default_sort
&& !matches!(
value.as_str(),
"date_desc" | "date_asc" | "sender_asc" | "sender_desc" | "subject_asc" | "subject_desc" | "size_asc" | "size_desc"
) {
"date_desc"
| "date_asc"
| "sender_asc"
| "sender_desc"
| "subject_asc"
| "subject_desc"
| "size_asc"
| "size_desc"
)
{
return Err("unsupported default_sort".to_string());
}
}
let current = get_preferences(conn)?;
let messages_per_page = data.messages_per_page.unwrap_or(current.messages_per_page);
let mark_read_delay_ms = data.mark_read_delay_ms.unwrap_or(current.mark_read_delay_ms);
let remote_images = data.remote_images.as_deref().unwrap_or(&current.remote_images);
let preferred_body = data.preferred_body.as_deref().unwrap_or(&current.preferred_body);
let mark_read_delay_ms = data
.mark_read_delay_ms
.unwrap_or(current.mark_read_delay_ms);
let remote_images = data
.remote_images
.as_deref()
.unwrap_or(&current.remote_images);
let preferred_body = data
.preferred_body
.as_deref()
.unwrap_or(&current.preferred_body);
let thread_mode = data.thread_mode.as_deref().unwrap_or(&current.thread_mode);
let collapse_quotes = data.collapse_quotes.unwrap_or(current.collapse_quotes);
let show_snippets = data.show_snippets.unwrap_or(current.show_snippets);
let default_sort = data.default_sort.as_deref().unwrap_or(&current.default_sort);
let default_sort = data
.default_sort
.as_deref()
.unwrap_or(&current.default_sort);
let flagged_on_top = data.flagged_on_top.unwrap_or(current.flagged_on_top);
let sync_imap_keywords = data.sync_imap_keywords.unwrap_or(current.sync_imap_keywords);
let sync_imap_keywords = data
.sync_imap_keywords
.unwrap_or(current.sync_imap_keywords);
conn.execute(
"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",
rusqlite::params![messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes as i32, show_snippets as i32, default_sort, flagged_on_top as i32, sync_imap_keywords as i32],
crate::queries::sqlite::queries::db_mail_preferences::Q_003,
rusqlite::params![
messages_per_page,
mark_read_delay_ms,
remote_images,
preferred_body,
thread_mode,
collapse_quotes as i32,
show_snippets as i32,
default_sort,
flagged_on_top as i32,
sync_imap_keywords as i32
],
)
.map_err(|error| format!("Failed to update mail preferences: {error}"))?;
+38 -78
View File
@@ -1,3 +1,4 @@
use crate::queries::sqlite::queries::db_messages::MSG_SELECT_COLS;
use rusqlite::{Connection, params};
use serde::Serialize;
@@ -35,7 +36,7 @@ pub struct CachedMessage {
}
fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMessage> {
let has_attachments_int: i32 = row.get(12)?;
let has_attachments_int: i32 = row.get(13)?;
Ok(CachedMessage {
uid: row.get(0)?,
folder: row.get(1)?,
@@ -49,17 +50,13 @@ fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMess
cc_addresses: row.get(9)?,
date: row.get(10)?,
flags: row.get(11)?,
size: row.get(13)?,
size: row.get(12)?,
has_attachments: has_attachments_int != 0,
snippet: row.get(14)?,
reaction: row.get(15)?,
})
}
const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, has_attachments, size, snippet, reaction";
#[allow(clippy::too_many_arguments)]
pub fn upsert_message(
conn: &Connection,
@@ -82,11 +79,7 @@ pub fn upsert_message(
) -> Result<(), String> {
let date_epoch = parse_date_to_epoch(date);
conn.execute(
"INSERT OR REPLACE INTO messages
(uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, size, has_attachments, snippet, date_epoch, reaction)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
crate::queries::sqlite::queries::db_messages::Q_001,
params![
uid,
folder,
@@ -123,14 +116,17 @@ pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Resul
pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize, String> {
let deleted = conn
.execute("DELETE FROM messages WHERE folder = ?1", params![folder])
.execute(
crate::queries::sqlite::queries::db_messages::Q_002,
params![folder],
)
.map_err(|e| format!("Failed to delete folder messages: {e}"))?;
Ok(deleted)
}
pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
conn.query_row(
"SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1",
crate::queries::sqlite::queries::db_messages::Q_003,
params![folder],
|row| row.get(0),
)
@@ -139,11 +135,7 @@ pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMessage>, String> {
let mut stmt = conn
.prepare(
"SELECT uid, folder, subject, from_address, from_name, to_addresses, \
COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \
FROM messages ORDER BY folder, uid",
)
.prepare(crate::queries::sqlite::queries::db_messages::Q_004)
.map_err(|e| format!("Failed to prepare index rebuild query: {e}"))?;
let rows = stmt
@@ -261,13 +253,7 @@ pub fn get_messages(
per_page: u32,
) -> Result<Vec<CachedMessage>, String> {
let offset = page * per_page;
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY date_epoch DESC
LIMIT ?2 OFFSET ?3"
);
let sql = crate::rustbox_query_db_messages_001!();
let mut stmt = conn
.prepare(&sql)
@@ -318,13 +304,7 @@ pub fn get_messages_sorted(
};
let offset = page * per_page;
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY {order_by}
LIMIT ?2 OFFSET ?3"
);
let sql = crate::rustbox_query_db_messages_002!(&order_by);
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare sorted get_messages: {e}"))?;
@@ -337,7 +317,7 @@ pub fn get_messages_sorted(
pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
conn.query_row(
"SELECT COUNT(*) FROM messages WHERE folder = ?1",
crate::queries::sqlite::queries::db_messages::Q_005,
params![folder],
|row| row.get(0),
)
@@ -346,7 +326,11 @@ pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
pub fn count_all_messages(conn: &Connection) -> Result<usize, String> {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
.query_row(
crate::queries::sqlite::queries::db_messages::Q_006,
[],
|row| row.get(0),
)
.map_err(|e| format!("Failed to count cached messages: {e}"))?;
usize::try_from(count).map_err(|_| "Cached message count is out of range".to_string())
}
@@ -358,7 +342,7 @@ pub fn update_message_flags(
flags_csv: &str,
) -> Result<(), String> {
conn.execute(
"UPDATE messages SET flags = ?1 WHERE folder = ?2 AND uid = ?3",
crate::queries::sqlite::queries::db_messages::Q_007,
params![flags_csv, folder, uid],
)
.map_err(|e| format!("Failed to update message flags: {e}"))?;
@@ -377,10 +361,7 @@ pub fn cache_message_body(
email_theme: Option<i32>,
) -> Result<(), String> {
conn.execute(
"UPDATE messages
SET body_html = ?1, body_text = ?2, body_cached = 1,
attachments_json = ?3, raw_headers = ?4, email_theme = ?5
WHERE folder = ?6 AND uid = ?7",
crate::queries::sqlite::queries::db_messages::Q_008,
params![
html,
text,
@@ -402,7 +383,7 @@ pub fn update_email_theme(
email_theme: i32,
) -> Result<(), String> {
conn.execute(
"UPDATE messages SET email_theme = ?1 WHERE folder = ?2 AND uid = ?3",
crate::queries::sqlite::queries::db_messages::Q_009,
params![email_theme, folder, uid],
)
.map_err(|e| format!("Failed to update email theme: {e}"))?;
@@ -423,9 +404,7 @@ pub fn get_cached_body(
uid: u32,
) -> Result<Option<CachedBody>, String> {
let result = conn.query_row(
"SELECT body_cached, body_html, body_text, attachments_json, raw_headers, email_theme
FROM messages
WHERE folder = ?1 AND uid = ?2",
crate::queries::sqlite::queries::db_messages::Q_010,
params![folder, uid],
|row| {
let cached: i32 = row.get(0)?;
@@ -455,13 +434,9 @@ pub fn get_single_message(
folder: &str,
uid: u32,
) -> Result<Option<CachedMessage>, String> {
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1 AND uid = ?2"
);
let sql = crate::rustbox_query_db_messages_003!();
let result = conn.query_row(&sql, params![folder, uid], |row| row_to_cached_message(row));
let result = conn.query_row(&sql, params![folder, uid], row_to_cached_message);
match result {
Ok(msg) => Ok(Some(msg)),
@@ -472,7 +447,7 @@ pub fn get_single_message(
pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
conn.execute(
"DELETE FROM messages WHERE folder = ?1 AND uid = ?2",
crate::queries::sqlite::queries::db_messages::Q_011,
params![folder, uid],
)
.map_err(|e| format!("Failed to delete message: {e}"))?;
@@ -481,12 +456,7 @@ pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), S
pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(String, u32)>, String> {
let mut stmt = conn
.prepare(
"SELECT folder, uid FROM messages
WHERE body_cached = 0
ORDER BY date_epoch DESC
LIMIT ?1",
)
.prepare(crate::queries::sqlite::queries::db_messages::Q_012)
.map_err(|e| format!("Failed to prepare get_unindexed_messages: {e}"))?;
let rows = stmt
@@ -507,7 +477,7 @@ pub fn get_all_uids_and_flags(
folder: &str,
) -> Result<Vec<(u32, String)>, String> {
let mut stmt = conn
.prepare("SELECT uid, flags FROM messages WHERE folder = ?1")
.prepare(crate::queries::sqlite::queries::db_messages::Q_013)
.map_err(|e| format!("Failed to prepare get_all_uids_and_flags: {e}"))?;
let rows = stmt
.query_map(params![folder], |row| {
@@ -528,14 +498,7 @@ pub fn get_thread_messages(
) -> Result<Vec<CachedMessage>, String> {
let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_");
let like_pattern = format!("%{}%", escaped_target);
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?1
OR in_reply_to = ?1
OR references_header LIKE ?2 ESCAPE '\\'
ORDER BY date_epoch ASC"
);
let sql = crate::rustbox_query_db_messages_004!();
let mut stmt = conn
.prepare(&sql)
@@ -732,14 +695,7 @@ pub fn search_messages_sqlite(
format!("WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
{where_clause}
ORDER BY date_epoch DESC
LIMIT ?{}",
param_values.len() + 1
);
let sql = crate::rustbox_query_db_messages_005!(&where_clause, param_values.len() + 1);
param_values.push(Box::new(limit as i64));
@@ -763,6 +719,15 @@ pub fn search_messages_sqlite(
Ok(results)
}
pub fn cached_storage_bytes(conn: &Connection) -> Result<Option<u64>, String> {
conn.query_row(
crate::queries::sqlite::queries::db_messages::Q_014,
[],
|row| row.get(0),
)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1314,8 +1279,3 @@ mod tests {
assert_eq!(thread[0].uid, 1);
}
}
pub fn cached_storage_bytes(conn: &Connection) -> Result<Option<u64>, String> {
conn.query_row("SELECT SUM(size) FROM messages", [], |row| row.get(0))
.map_err(|e| e.to_string())
}
+2 -2
View File
@@ -3,16 +3,16 @@ pub mod contact_groups;
pub mod contacts;
pub mod display_preferences;
pub mod drafts;
pub mod folders;
pub mod folder_preferences;
pub mod folders;
pub(crate) mod header_sync;
pub mod identities;
pub mod mail_preferences;
pub mod messages;
pub mod notification_preferences;
pub mod pgp;
pub mod response_templates;
pub mod pool;
pub mod response_templates;
pub mod special_folders;
pub mod tags;
pub mod trusted_senders;
+12 -4
View File
@@ -24,7 +24,7 @@ pub struct UpdateNotificationPreferences {
pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, String> {
let result = conn.query_row(
"SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1",
crate::queries::sqlite::queries::db_notification_preferences::Q_001,
[],
|row| {
let enabled_int: i32 = row.get(0)?;
@@ -34,7 +34,15 @@ pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, Str
let dnd_start: String = row.get(4)?;
let dnd_end: String = row.get(5)?;
let updated_at: String = row.get(6)?;
Ok((enabled_int, sound_int, folders_json, dnd_enabled_int, dnd_start, dnd_end, updated_at))
Ok((
enabled_int,
sound_int,
folders_json,
dnd_enabled_int,
dnd_start,
dnd_end,
updated_at,
))
},
);
@@ -78,7 +86,7 @@ pub fn update_preferences(
data: &UpdateNotificationPreferences,
) -> Result<NotificationPreferences, String> {
conn.execute(
"INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)",
crate::queries::sqlite::queries::db_notification_preferences::Q_002,
[],
)
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
@@ -132,7 +140,7 @@ pub fn update_preferences(
sets.push("updated_at = datetime('now')".to_string());
let set_clause = sets.join(", ");
let sql = format!("UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}");
let sql = crate::rustbox_query_db_notification_preferences_001!(&set_clause, idx);
values.push(Box::new(1_i32));
let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect();
+41 -25
View File
@@ -1,4 +1,5 @@
use rusqlite::{params, Connection};
use crate::queries::sqlite::queries::db_pgp::KEY_COLS;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
@@ -48,22 +49,23 @@ fn row_to_key(row: &rusqlite::Row<'_>) -> rusqlite::Result<PgpKeyInternal> {
})
}
const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at";
pub fn list_keys(conn: &Connection) -> Result<Vec<PgpKeyRecord>, String> {
let mut stmt = conn
.prepare(&format!("SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint"))
.prepare(&crate::rustbox_query_db_pgp_001!())
.map_err(|e| format!("Failed to prepare PGP key list: {e}"))?;
let rows = stmt
.query_map([], row_to_key)
.map_err(|e| format!("Failed to list PGP keys: {e}"))?;
rows.map(|row| row.map(|v| v.record).map_err(|e| format!("Failed to read PGP key: {e}")))
rows.map(|row| {
row.map(|v| v.record)
.map_err(|e| format!("Failed to read PGP key: {e}"))
})
.collect()
}
pub fn get_key(conn: &Connection, fingerprint: &str) -> Result<Option<PgpKeyInternal>, String> {
let normalized = normalize_fingerprint(fingerprint);
let sql = format!("SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1");
let sql = crate::rustbox_query_db_pgp_002!();
match conn.query_row(&sql, params![normalized], row_to_key) {
Ok(v) => Ok(Some(v)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@@ -73,12 +75,15 @@ pub fn get_key(conn: &Connection, fingerprint: &str) -> Result<Option<PgpKeyInte
pub fn find_keys_by_email(conn: &Connection, email: &str) -> Result<Vec<PgpKeyRecord>, String> {
let mut stmt = conn
.prepare(&format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC"))
.prepare(&crate::rustbox_query_db_pgp_003!())
.map_err(|e| format!("Failed to prepare PGP email lookup: {e}"))?;
let rows = stmt
.query_map(params![email.trim()], row_to_key)
.map_err(|e| format!("Failed to query PGP keys by email: {e}"))?;
rows.map(|row| row.map(|v| v.record).map_err(|e| format!("Failed to read PGP key: {e}")))
rows.map(|row| {
row.map(|v| v.record)
.map_err(|e| format!("Failed to read PGP key: {e}"))
})
.collect()
}
@@ -88,7 +93,7 @@ pub fn upsert_key(
secret_key_path: Option<&str>,
) -> Result<(), String> {
conn.execute(
"INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=datetime('now')",
crate::queries::sqlite::queries::db_pgp::Q_001,
params![
record.fingerprint,
record.key_id,
@@ -108,7 +113,7 @@ pub fn upsert_key(
pub fn delete_key(conn: &Connection, fingerprint: &str) -> Result<bool, String> {
Ok(conn
.execute(
"DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1",
crate::queries::sqlite::queries::db_pgp::Q_002,
params![normalize_fingerprint(fingerprint)],
)
.map_err(|e| format!("Failed to delete PGP key: {e}"))?
@@ -121,7 +126,7 @@ pub fn set_trust(conn: &Connection, fingerprint: &str, trust: &str) -> Result<bo
}
Ok(conn
.execute(
"UPDATE pgp_keys SET trust=?2,updated_at=datetime('now') WHERE replace(upper(fingerprint),' ','')=?1",
crate::queries::sqlite::queries::db_pgp::Q_003,
params![normalize_fingerprint(fingerprint), trust],
)
.map_err(|e| format!("Failed to update PGP trust: {e}"))?
@@ -129,35 +134,45 @@ pub fn set_trust(conn: &Connection, fingerprint: &str, trust: &str) -> Result<bo
}
pub fn get_preferences(conn: &Connection) -> Result<PgpPreferences, String> {
conn.query_row(
"SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1",
[],
|row| {
conn.query_row(crate::queries::sqlite::queries::db_pgp::Q_004, [], |row| {
Ok(PgpPreferences {
wkd_enabled: row.get::<_, i32>(0)? != 0,
hkp_enabled: row.get::<_, i32>(1)? != 0,
require_trusted_encryption: row.get::<_, i32>(2)? != 0,
})
},
)
})
.map_err(|e| format!("Failed to read PGP preferences: {e}"))
}
pub fn update_preferences(conn: &Connection, prefs: &PgpPreferences) -> Result<PgpPreferences, String> {
pub fn update_preferences(
conn: &Connection,
prefs: &PgpPreferences,
) -> Result<PgpPreferences, String> {
conn.execute(
"UPDATE pgp_preferences SET wkd_enabled=?1,hkp_enabled=?2,require_trusted_encryption=?3,updated_at=datetime('now') WHERE id=1",
params![prefs.wkd_enabled as i32, prefs.hkp_enabled as i32, prefs.require_trusted_encryption as i32],
crate::queries::sqlite::queries::db_pgp::Q_005,
params![
prefs.wkd_enabled as i32,
prefs.hkp_enabled as i32,
prefs.require_trusted_encryption as i32
],
)
.map_err(|e| format!("Failed to update PGP preferences: {e}"))?;
get_preferences(conn)
}
pub fn normalize_fingerprint(value: &str) -> String {
value.chars().filter(|c| !c.is_ascii_whitespace()).flat_map(char::to_uppercase).collect()
value
.chars()
.filter(|c| !c.is_ascii_whitespace())
.flat_map(char::to_uppercase)
.collect()
}
pub fn find_key_internal_by_email(conn: &Connection, email: &str) -> Result<Option<PgpKeyInternal>, String> {
let sql = format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1");
pub fn find_key_internal_by_email(
conn: &Connection,
email: &str,
) -> Result<Option<PgpKeyInternal>, String> {
let sql = crate::rustbox_query_db_pgp_004!();
match conn.query_row(&sql, params![email.trim()], row_to_key) {
Ok(value) => Ok(Some(value)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
@@ -167,10 +182,11 @@ pub fn find_key_internal_by_email(conn: &Connection, email: &str) -> Result<Opti
pub fn list_secret_keys(conn: &Connection) -> Result<Vec<PgpKeyInternal>, String> {
let mut stmt = conn
.prepare(&format!("SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint"))
.prepare(&crate::rustbox_query_db_pgp_005!())
.map_err(|e| format!("Failed to prepare PGP secret key list: {e}"))?;
let rows = stmt
.query_map([], row_to_key)
.map_err(|e| format!("Failed to list PGP secret keys: {e}"))?;
rows.map(|row| row.map_err(|e| format!("Failed to read PGP secret key: {e}"))).collect()
rows.map(|row| row.map_err(|e| format!("Failed to read PGP secret key: {e}")))
.collect()
}
+78 -70
View File
@@ -32,197 +32,203 @@ impl UserDb {
}
const MIGRATIONS: &[(u32, &str)] = &[
(1, include_str!("../../migrations/V001__initial_schema.sql")),
(
1,
include_str!("../../migrations/sqlite/V001__initial_schema.sql"),
),
(
2,
include_str!("../../migrations/V002__folders_and_messages.sql"),
include_str!("../../migrations/sqlite/V002__folders_and_messages.sql"),
),
(
3,
include_str!("../../migrations/sqlite/V003__add_date_epoch.sql"),
),
(3, include_str!("../../migrations/V003__add_date_epoch.sql")),
(
4,
include_str!("../../migrations/V004__folder_messages_updated_at.sql"),
include_str!("../../migrations/sqlite/V004__folder_messages_updated_at.sql"),
),
(
5,
include_str!("../../migrations/V005__drafts_and_attachments.sql"),
include_str!("../../migrations/sqlite/V005__drafts_and_attachments.sql"),
),
(
6,
include_str!("../../migrations/V006__cache_attachments_and_headers.sql"),
include_str!("../../migrations/sqlite/V006__cache_attachments_and_headers.sql"),
),
(
7,
include_str!("../../migrations/sqlite/V007__contacts.sql"),
),
(
8,
include_str!("../../migrations/sqlite/V008__identities.sql"),
),
(7, include_str!("../../migrations/V007__contacts.sql")),
(8, include_str!("../../migrations/V008__identities.sql")),
(
9,
include_str!("../../migrations/V009__notification_preferences.sql"),
include_str!("../../migrations/sqlite/V009__notification_preferences.sql"),
),
(
10,
include_str!("../../migrations/V010__contact_groups.sql"),
include_str!("../../migrations/sqlite/V010__contact_groups.sql"),
),
(
11,
include_str!("../../migrations/V011__message_reaction.sql"),
include_str!("../../migrations/sqlite/V011__message_reaction.sql"),
),
(12, include_str!("../../migrations/V012__tags.sql")),
(12, include_str!("../../migrations/sqlite/V012__tags.sql")),
(
13,
include_str!("../../migrations/V013__display_preferences.sql"),
include_str!("../../migrations/sqlite/V013__display_preferences.sql"),
),
(
14,
include_str!("../../migrations/V014__compose_format.sql"),
include_str!("../../migrations/sqlite/V014__compose_format.sql"),
),
(
15,
include_str!("../../migrations/sqlite/V015__deep_index.sql"),
),
(
16,
include_str!("../../migrations/sqlite/V016__calendar.sql"),
),
(15, include_str!("../../migrations/V015__deep_index.sql")),
(16, include_str!("../../migrations/V016__calendar.sql")),
(
17,
include_str!("../../migrations/V017__add_email_theme.sql"),
include_str!("../../migrations/sqlite/V017__add_email_theme.sql"),
),
(
18,
include_str!("../../migrations/V018__animation_mode.sql"),
include_str!("../../migrations/sqlite/V018__animation_mode.sql"),
),
(
19,
include_str!("../../migrations/V019__known_addresses.sql"),
include_str!("../../migrations/sqlite/V019__known_addresses.sql"),
),
(
20,
include_str!("../../migrations/V020__appearance_personalization.sql"),
include_str!("../../migrations/sqlite/V020__appearance_personalization.sql"),
),
(
21,
include_str!("../../migrations/V021__special_folders.sql"),
include_str!("../../migrations/sqlite/V021__special_folders.sql"),
),
(
22,
include_str!("../../migrations/V022__identity_options.sql"),
include_str!("../../migrations/sqlite/V022__identity_options.sql"),
),
(
23,
include_str!("../../migrations/V023__saved_searches.sql"),
include_str!("../../migrations/sqlite/V023__saved_searches.sql"),
),
(
24,
include_str!("../../migrations/V024__notification_dnd.sql"),
include_str!("../../migrations/sqlite/V024__notification_dnd.sql"),
),
(
25,
include_str!("../../migrations/V025__custom_theme_colors.sql"),
include_str!("../../migrations/sqlite/V025__custom_theme_colors.sql"),
),
(
26,
include_str!("../../migrations/V026__mail_preferences.sql"),
include_str!("../../migrations/sqlite/V026__mail_preferences.sql"),
),
(
27,
include_str!("../../migrations/V027__contact_profile.sql"),
include_str!("../../migrations/sqlite/V027__contact_profile.sql"),
),
(
28,
include_str!("../../migrations/V028__contact_multi_values.sql"),
include_str!("../../migrations/sqlite/V028__contact_multi_values.sql"),
),
(
29,
include_str!("../../migrations/V029__draft_send_options.sql"),
include_str!("../../migrations/sqlite/V029__draft_send_options.sql"),
),
(
30,
include_str!("../../migrations/V030__folder_list_preferences.sql"),
include_str!("../../migrations/sqlite/V030__folder_list_preferences.sql"),
),
(
31,
include_str!("../../migrations/V031__compose_preferences.sql"),
include_str!("../../migrations/sqlite/V031__compose_preferences.sql"),
),
(
32,
include_str!("../../migrations/V032__advanced_appearance.sql"),
include_str!("../../migrations/sqlite/V032__advanced_appearance.sql"),
),
(
33,
include_str!("../../migrations/V033__draft_compose_format.sql"),
include_str!("../../migrations/sqlite/V033__draft_compose_format.sql"),
),
(
34,
include_str!("../../migrations/V034__imap_keyword_sync.sql"),
include_str!("../../migrations/sqlite/V034__imap_keyword_sync.sql"),
),
(
35,
include_str!("../../migrations/V035__compose_behavior_preferences.sql"),
include_str!("../../migrations/sqlite/V035__compose_behavior_preferences.sql"),
),
(
36,
include_str!("../../migrations/V036__remote_draft_tracking.sql"),
include_str!("../../migrations/sqlite/V036__remote_draft_tracking.sql"),
),
(
37,
include_str!("../../migrations/V037__openpgp_keyring.sql"),
include_str!("../../migrations/sqlite/V037__openpgp_keyring.sql"),
),
(
38,
include_str!("../../migrations/V038__identity_openpgp.sql"),
include_str!("../../migrations/sqlite/V038__identity_openpgp.sql"),
),
(
39,
include_str!("../../migrations/V039__draft_openpgp_options.sql"),
include_str!("../../migrations/sqlite/V039__draft_openpgp_options.sql"),
),
(
40,
include_str!("../../migrations/V040__draft_openpgp_attach_key.sql"),
include_str!("../../migrations/sqlite/V040__draft_openpgp_attach_key.sql"),
),
(
41,
include_str!("../../migrations/V041__draft_openpgp_recipient_keys.sql"),
include_str!("../../migrations/sqlite/V041__draft_openpgp_recipient_keys.sql"),
),
(
42,
include_str!("../../migrations/V042__trusted_senders.sql"),
include_str!("../../migrations/sqlite/V042__trusted_senders.sql"),
),
(
43,
include_str!("../../migrations/V043__response_templates.sql"),
include_str!("../../migrations/sqlite/V043__response_templates.sql"),
),
(
44,
include_str!("../../migrations/V044__interface_skin.sql"),
include_str!("../../migrations/sqlite/V044__interface_skin.sql"),
),
(
45,
include_str!("../../migrations/V045__response_template_format.sql"),
include_str!("../../migrations/sqlite/V045__response_template_format.sql"),
),
(
46,
include_str!("../../migrations/V046__preset_scale_unlock.sql"),
include_str!("../../migrations/sqlite/V046__preset_scale_unlock.sql"),
),
];
fn run_migrations(conn: &Connection) -> Result<(), String> {
let mut current: u32 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
let current: u32 = conn
.query_row(crate::queries::sqlite::queries::db_pool::Q_001, [], |row| {
row.get(0)
})
.map_err(|e| format!("Failed to read user_version: {e}"))?;
let refinery_max: Option<u32> = conn
.query_row(
"SELECT MAX(version) FROM refinery_schema_history",
[],
|row| row.get(0),
)
.unwrap_or(None);
if let Some(v) = refinery_max
&& v > current
{
current = v;
conn.execute_batch(&format!("PRAGMA user_version = {current};"))
.map_err(|e| format!("Failed to sync user_version from refinery: {e}"))?;
}
for &(version, sql) in MIGRATIONS {
if version > current {
conn.execute_batch(sql)
.map_err(|e| format!("Migration V{version:03} failed: {e}"))?;
conn.execute_batch(&format!("PRAGMA user_version = {version};"))
conn.execute_batch(&crate::queries::sqlite::queries::db_pool::set_user_version(
version,
))
.map_err(|e| format!("Failed to set user_version to {version}: {e}"))?;
}
}
@@ -237,10 +243,10 @@ pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, Strin
let db_path = dir.join("db.sqlite");
let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
conn.execute_batch("PRAGMA journal_mode=WAL;")
conn.execute_batch(crate::queries::sqlite::queries::db_pool::Q_002)
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
conn.execute_batch("PRAGMA foreign_keys=ON;")
conn.execute_batch(crate::queries::sqlite::queries::db_pool::Q_003)
.map_err(|e| format!("Failed to enable foreign keys: {e}"))?;
run_migrations(&conn)?;
@@ -252,7 +258,7 @@ pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, Strin
pub fn open_test_db() -> Connection {
let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite");
conn.execute_batch("PRAGMA foreign_keys=ON;")
conn.execute_batch(crate::queries::sqlite::queries::db_pool::Q_004)
.expect("Failed to enable foreign keys");
run_migrations(&conn).expect("Test migrations failed");
@@ -269,7 +275,7 @@ mod tests {
let conn = open_test_db();
let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.prepare(crate::queries::sqlite::queries::db_pool::Q_005)
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
@@ -294,7 +300,9 @@ mod tests {
assert!(db_file.exists());
let fk: i32 = conn
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
.query_row(crate::queries::sqlite::queries::db_pool::Q_006, [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(fk, 1);
+17 -8
View File
@@ -24,7 +24,7 @@ fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ResponseTemplate> {
pub fn list(conn: &Connection) -> Result<Vec<ResponseTemplate>, String> {
let mut stmt = conn
.prepare("SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name COLLATE NOCASE")
.prepare(crate::queries::sqlite::queries::db_response_templates::Q_001)
.map_err(|e| format!("Failed to prepare response templates: {e}"))?;
let rows = stmt
.query_map([], from_row)
@@ -35,7 +35,7 @@ pub fn list(conn: &Connection) -> Result<Vec<ResponseTemplate>, String> {
pub fn get(conn: &Connection, id: i64) -> Result<Option<ResponseTemplate>, String> {
match conn.query_row(
"SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=?1",
crate::queries::sqlite::queries::db_response_templates::Q_002,
params![id],
from_row,
) {
@@ -67,7 +67,7 @@ pub fn create(
_ => return Err("Invalid response template format".to_string()),
};
conn.execute(
"INSERT INTO response_templates(name,body,format) VALUES (?1,?2,?3)",
crate::queries::sqlite::queries::db_response_templates::Q_003,
params![name, body, format],
)
.map_err(|e| {
@@ -103,12 +103,18 @@ pub fn update(
"text" | "" => "text",
_ => return Err("Invalid response template format".to_string()),
};
let changed = conn.execute(
"UPDATE response_templates SET name=?1,body=?2,format=?3,updated_at=datetime('now') WHERE id=?4",
let changed = conn
.execute(
crate::queries::sqlite::queries::db_response_templates::Q_004,
params![name, body, format, id],
).map_err(|e| if e.to_string().contains("UNIQUE constraint failed") {
)
.map_err(|e| {
if e.to_string().contains("UNIQUE constraint failed") {
"A response template with this name already exists".to_string()
} else { format!("Failed to update response template: {e}") })?;
} else {
format!("Failed to update response template: {e}")
}
})?;
if changed == 0 {
return Ok(None);
}
@@ -117,7 +123,10 @@ pub fn update(
pub fn delete(conn: &Connection, id: i64) -> Result<bool, String> {
Ok(conn
.execute("DELETE FROM response_templates WHERE id=?1", params![id])
.execute(
crate::queries::sqlite::queries::db_response_templates::Q_005,
params![id],
)
.map_err(|e| format!("Failed to delete response template: {e}"))?
> 0)
}
+25 -11
View File
@@ -1,4 +1,4 @@
use rusqlite::{params, Connection};
use rusqlite::{Connection, params};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
@@ -22,7 +22,7 @@ fn row_to_saved(row: &rusqlite::Row<'_>) -> rusqlite::Result<SavedSearch> {
pub fn list(conn: &Connection) -> Result<Vec<SavedSearch>, String> {
let mut stmt = conn
.prepare("SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name COLLATE NOCASE")
.prepare(crate::queries::sqlite::queries::db_saved_searches::Q_001)
.map_err(|e| format!("Failed to prepare saved searches: {e}"))?;
let rows = stmt
.query_map([], row_to_saved)
@@ -38,7 +38,7 @@ pub fn create(conn: &Connection, name: &str, query_string: &str) -> Result<Saved
return Err("Saved search name and query are required".to_string());
}
conn.execute(
"INSERT INTO saved_searches(name,query_string) VALUES (?1,?2)",
crate::queries::sqlite::queries::db_saved_searches::Q_002,
params![name, query_string],
)
.map_err(|e| format!("Failed to save search: {e}"))?;
@@ -46,23 +46,32 @@ pub fn create(conn: &Connection, name: &str, query_string: &str) -> Result<Saved
get(conn, id)?.ok_or_else(|| "Failed to read saved search".to_string())
}
pub fn update(conn: &Connection, id: i64, name: &str, query_string: &str) -> Result<Option<SavedSearch>, String> {
pub fn update(
conn: &Connection,
id: i64,
name: &str,
query_string: &str,
) -> Result<Option<SavedSearch>, String> {
let name = name.trim();
let query_string = query_string.trim();
if name.is_empty() || query_string.is_empty() {
return Err("Saved search name and query are required".to_string());
}
let changed = conn.execute(
"UPDATE saved_searches SET name=?1,query_string=?2,updated_at=datetime('now') WHERE id=?3",
let changed = conn
.execute(
crate::queries::sqlite::queries::db_saved_searches::Q_003,
params![name, query_string, id],
).map_err(|e| format!("Failed to update saved search: {e}"))?;
if changed == 0 { return Ok(None); }
)
.map_err(|e| format!("Failed to update saved search: {e}"))?;
if changed == 0 {
return Ok(None);
}
get(conn, id)
}
pub fn get(conn: &Connection, id: i64) -> Result<Option<SavedSearch>, String> {
match conn.query_row(
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=?1",
crate::queries::sqlite::queries::db_saved_searches::Q_004,
params![id],
row_to_saved,
) {
@@ -73,6 +82,11 @@ pub fn get(conn: &Connection, id: i64) -> Result<Option<SavedSearch>, String> {
}
pub fn delete(conn: &Connection, id: i64) -> Result<bool, String> {
Ok(conn.execute("DELETE FROM saved_searches WHERE id=?1", params![id])
.map_err(|e| format!("Failed to delete saved search: {e}"))? > 0)
Ok(conn
.execute(
crate::queries::sqlite::queries::db_saved_searches::Q_005,
params![id],
)
.map_err(|e| format!("Failed to delete saved search: {e}"))?
> 0)
}
+9 -6
View File
@@ -21,7 +21,7 @@ pub fn get_role(conn: &Connection, role: &str) -> Result<Option<String>, String>
return Err(format!("Invalid special folder role: {role}"));
}
conn.query_row(
"SELECT folder_name FROM special_folders WHERE role = ?1",
crate::queries::sqlite::queries::db_special_folders::Q_001,
[role],
|row| row.get(0),
)
@@ -47,7 +47,7 @@ pub fn set_role(conn: &Connection, role: &str, folder_name: Option<&str>) -> Res
Some(folder) => {
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
crate::queries::sqlite::queries::db_special_folders::Q_002,
[folder],
|row| row.get(0),
)
@@ -56,13 +56,16 @@ pub fn set_role(conn: &Connection, role: &str, folder_name: Option<&str>) -> Res
return Err(format!("Folder does not exist: {folder}"));
}
conn.execute(
"INSERT INTO special_folders(role, folder_name, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = datetime('now')",
crate::queries::sqlite::queries::db_special_folders::Q_003,
params![role, folder],
)
.map_err(|e| format!("Failed to set special folder {role}: {e}"))?;
}
None => {
conn.execute("DELETE FROM special_folders WHERE role = ?1", [role])
conn.execute(
crate::queries::sqlite::queries::db_special_folders::Q_004,
[role],
)
.map_err(|e| format!("Failed to clear special folder {role}: {e}"))?;
}
}
@@ -84,7 +87,7 @@ pub fn rename_folder_reference(
new_name: &str,
) -> Result<(), String> {
conn.execute(
"UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2",
crate::queries::sqlite::queries::db_special_folders::Q_005,
params![new_name, old_name],
)
.map_err(|e| format!("Failed to update special folder reference: {e}"))?;
@@ -93,7 +96,7 @@ pub fn rename_folder_reference(
pub fn clear_folder_reference(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute(
"DELETE FROM special_folders WHERE folder_name = ?1",
crate::queries::sqlite::queries::db_special_folders::Q_006,
[folder_name],
)
.map_err(|e| format!("Failed to clear special folder reference: {e}"))?;
+15 -45
View File
@@ -33,7 +33,7 @@ pub fn imap_keyword_for_tag_id(id: &str) -> String {
pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<(), String> {
let imap_keyword = imap_keyword_for_tag_id(id);
conn.execute(
"INSERT INTO tags (id, name, color, imap_keyword) VALUES (?1, ?2, ?3, ?4)",
crate::queries::sqlite::queries::db_tags::Q_001,
params![id, name, color, imap_keyword],
)
.map_err(|e| format!("Failed to create tag: {e}"))?;
@@ -42,12 +42,7 @@ pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
pub fn list_tags(conn: &Connection) -> Result<Vec<Tag>, String> {
let mut stmt = conn
.prepare(
"SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at,
(SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count
FROM tags t
ORDER BY t.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_tags::Q_002)
.map_err(|e| format!("Failed to prepare list_tags: {e}"))?;
let rows = stmt
@@ -81,7 +76,7 @@ pub fn get_message_refs_by_tag(
tag_id: &str,
) -> Result<Vec<(u32, String)>, String> {
let mut stmt = conn
.prepare("SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?1")
.prepare(crate::queries::sqlite::queries::db_tags::Q_003)
.map_err(|e| format!("Failed to prepare tag message refs: {e}"))?;
let rows = stmt
.query_map(params![tag_id], |row| Ok((row.get(0)?, row.get(1)?)))
@@ -140,11 +135,9 @@ pub fn reconcile_imap_keywords(
add_tag_to_message(conn, &tag.id, uid, folder)?;
added += 1;
}
(false, true) => {
if remove_tag_from_message(conn, &tag.id, uid, folder)? {
(false, true) if remove_tag_from_message(conn, &tag.id, uid, folder)? => {
removed += 1;
}
}
_ => {}
}
}
@@ -154,7 +147,7 @@ pub fn reconcile_imap_keywords(
pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<bool, String> {
let updated = conn
.execute(
"UPDATE tags SET name = ?1, color = ?2, updated_at = datetime('now') WHERE id = ?3",
crate::queries::sqlite::queries::db_tags::Q_004,
params![name, color, id],
)
.map_err(|e| format!("Failed to update tag: {e}"))?;
@@ -163,7 +156,7 @@ pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn
.execute("DELETE FROM tags WHERE id = ?1", params![id])
.execute(crate::queries::sqlite::queries::db_tags::Q_005, params![id])
.map_err(|e| format!("Failed to delete tag: {e}"))?;
Ok(deleted > 0)
}
@@ -175,7 +168,7 @@ pub fn add_tag_to_message(
folder: &str,
) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES (?1, ?2, ?3)",
crate::queries::sqlite::queries::db_tags::Q_006,
params![tag_id, uid, folder],
)
.map_err(|e| format!("Failed to add tag to message: {e}"))?;
@@ -190,7 +183,7 @@ pub fn remove_tag_from_message(
) -> Result<bool, String> {
let deleted = conn
.execute(
"DELETE FROM message_tags WHERE tag_id = ?1 AND message_uid = ?2 AND message_folder = ?3",
crate::queries::sqlite::queries::db_tags::Q_007,
params![tag_id, uid, folder],
)
.map_err(|e| format!("Failed to remove tag from message: {e}"))?;
@@ -203,13 +196,7 @@ pub fn get_message_tags(
folder: &str,
) -> Result<Vec<MessageTag>, String> {
let mut stmt = conn
.prepare(
"SELECT t.id, t.name, t.color
FROM tags t
JOIN message_tags mt ON mt.tag_id = t.id
WHERE mt.message_uid = ?1 AND mt.message_folder = ?2
ORDER BY t.name ASC",
)
.prepare(crate::queries::sqlite::queries::db_tags::Q_008)
.map_err(|e| format!("Failed to prepare get_message_tags: {e}"))?;
let rows = stmt
@@ -249,14 +236,7 @@ pub fn get_tags_for_messages(
})
.collect();
let sql = format!(
"SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC",
conditions.join(" OR ")
);
let sql = crate::rustbox_query_db_tags_001!(conditions.join(" OR "));
let mut stmt = conn
.prepare(&sql)
@@ -299,16 +279,7 @@ pub fn get_messages_by_tag(
) -> Result<Vec<CachedMessage>, String> {
let offset = page * per_page;
let mut stmt = conn
.prepare(
"SELECT m.uid, m.folder, m.message_id, m.in_reply_to, m.references_header,
m.subject, m.from_address, m.from_name, m.to_addresses, m.cc_addresses,
m.date, m.flags, m.size, m.has_attachments, m.snippet, m.reaction
FROM messages m
JOIN message_tags mt ON mt.message_uid = m.uid AND mt.message_folder = m.folder
WHERE mt.tag_id = ?1
ORDER BY m.date_epoch DESC
LIMIT ?2 OFFSET ?3",
)
.prepare(crate::queries::sqlite::queries::db_tags::Q_009)
.map_err(|e| format!("Failed to prepare get_messages_by_tag: {e}"))?;
let rows = stmt
@@ -344,7 +315,7 @@ pub fn get_messages_by_tag(
pub fn count_messages_by_tag(conn: &Connection, tag_id: &str) -> Result<u32, String> {
conn.query_row(
"SELECT COUNT(*) FROM message_tags WHERE tag_id = ?1",
crate::queries::sqlite::queries::db_tags::Q_010,
params![tag_id],
|row| row.get(0),
)
@@ -358,13 +329,12 @@ mod tests {
fn insert_test_message(conn: &Connection, folder: &str, uid: u32) {
conn.execute(
"INSERT OR IGNORE INTO folders (name) VALUES (?1)",
crate::queries::sqlite::queries::db_tags::Q_011,
params![folder],
)
.unwrap();
conn.execute(
"INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet)
VALUES (?1, ?2, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')",
crate::queries::sqlite::queries::db_tags::Q_012,
params![uid, folder],
)
.unwrap();
@@ -462,7 +432,7 @@ mod tests {
let map = get_tags_for_messages(&conn, &refs).unwrap();
assert_eq!(map.get(&(1, "INBOX".to_string())).unwrap().len(), 1);
assert!(map.get(&(2, "INBOX".to_string())).is_none());
assert!(!map.contains_key(&(2, "INBOX".to_string())));
}
#[test]
+3 -3
View File
@@ -2,7 +2,7 @@ use rusqlite::Connection;
pub fn list(conn: &Connection) -> Result<Vec<String>, String> {
let mut stmt = conn
.prepare("SELECT email FROM trusted_senders ORDER BY lower(email)")
.prepare(crate::queries::sqlite::queries::db_trusted_senders::Q_001)
.map_err(|e| format!("Failed to prepare trusted sender list: {e}"))?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
@@ -13,7 +13,7 @@ pub fn list(conn: &Connection) -> Result<Vec<String>, String> {
pub fn add(conn: &Connection, email: &str) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO trusted_senders(email) VALUES(?1)",
crate::queries::sqlite::queries::db_trusted_senders::Q_002,
[email],
)
.map_err(|e| format!("Failed to trust sender: {e}"))?;
@@ -22,7 +22,7 @@ pub fn add(conn: &Connection, email: &str) -> Result<(), String> {
pub fn remove(conn: &Connection, email: &str) -> Result<(), String> {
conn.execute(
"DELETE FROM trusted_senders WHERE email = ?1 COLLATE NOCASE",
crate::queries::sqlite::queries::db_trusted_senders::Q_003,
[email],
)
.map_err(|e| format!("Failed to remove trusted sender: {e}"))?;
+11 -2
View File
@@ -6,9 +6,11 @@ mod email_theme;
mod error;
mod imap;
mod pgp;
mod queries;
mod realtime;
mod routes;
mod search;
mod session_store;
mod sieve;
mod smtp;
mod ui;
@@ -54,9 +56,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
})?;
let config = Arc::new(loaded_config);
let store = Arc::new(SessionStore::new_with_tls_verify(
let persistence = session_store::from_config(&config)
.await
.map_err(std::io::Error::other)?;
let session_crypto = session_store::crypto::SessionCrypto::load_or_create(&config)
.map_err(std::io::Error::other)?;
let store = Arc::new(SessionStore::new_persistent(
Duration::from_secs(config.session_timeout_hours * 3600),
config.tls_verify,
persistence,
session_crypto,
));
{
@@ -65,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
interval.tick().await;
store.purge_expired();
store.purge_expired_shared().await;
tracing::debug!("Purged expired sessions");
}
});
+1 -1
View File
@@ -126,7 +126,7 @@ async fn https_get_address(
format!("{host}:{port}")
};
let request = format!(
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.6\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n"
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.1.0\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n"
);
timeout(duration, stream.write_all(request.as_bytes()))
.await
+3 -3
View File
@@ -286,8 +286,9 @@ pub fn decrypt_inline(
let mut signer_fingerprint = None;
if signed {
for armor in public_armors {
if let Ok((public, _)) = SignedPublicKey::from_string(armor) {
if decrypted.verify(&public).is_ok() {
if let Ok((public, _)) = SignedPublicKey::from_string(armor)
&& decrypted.verify(&public).is_ok()
{
verified = true;
signer_fingerprint = Some(normalize_fingerprint(&format!(
"{}",
@@ -297,7 +298,6 @@ pub fn decrypt_inline(
}
}
}
}
Ok(DecryptResult {
plaintext,
+9
View File
@@ -0,0 +1,9 @@
// Query catalogs intentionally expose parallel SQL dialects. Not every catalog
// is used in a SQLite-only process, so dead-code suppression is scoped to the
// dialect modules instead of the rest of the crate.
#[allow(dead_code)]
pub mod mysql;
#[allow(dead_code)]
pub mod pgsql;
#[allow(dead_code)]
pub mod sqlite;
+1
View File
@@ -0,0 +1 @@
pub mod queries;
+487
View File
@@ -0,0 +1,487 @@
pub mod auth_user_data {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'schema_version'";
}
pub mod db_calendar {
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_002: &str = "INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
recurrence_rule, attendees, organizer, status, source, source_uid,
source_message_uid, source_message_folder, meeting_url, color,
reminder_minutes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
pub const Q_003: &str = "DELETE FROM calendar_events WHERE id = ?";
pub const Q_004: &str = "INSERT IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_005: &str = "INSERT IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_006: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates ORDER BY is_default DESC, name ASC";
pub const Q_007: &str =
"INSERT INTO meeting_templates (name, url_template, icon) VALUES (?, ?, ?)";
pub const Q_008: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates WHERE id = ?";
pub const Q_009: &str = "DELETE FROM meeting_templates WHERE id = ?";
pub const FORMAT_001: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ? AND start_time <= ?
ORDER BY start_time ASC";
pub const FORMAT_002: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC";
pub const FORMAT_003: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?";
pub const FORMAT_004: &str = "UPDATE calendar_events SET {} WHERE id = ?";
pub const FORMAT_005: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?";
pub const FORMAT_006: &str =
"SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1";
pub const FORMAT_007: &str = "UPDATE calendar_settings SET {} WHERE id = 1";
}
pub mod db_contact_groups {
pub const Q_001: &str = "INSERT INTO contact_groups (id, name) VALUES (?, ?)";
pub const Q_002: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count
FROM contact_groups g
ORDER BY g.name ASC";
pub const Q_003: &str =
"UPDATE contact_groups SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q_004: &str = "DELETE FROM contact_groups WHERE id = ?";
pub const Q_005: &str =
"INSERT IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?, ?)";
pub const Q_006: &str =
"DELETE FROM contact_group_members WHERE group_id = ? AND contact_id = ?";
pub const Q_007: &str = "SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite,
c.last_contacted, c.contact_count, c.source, c.created_at, c.updated_at, c.emails_json, c.phones_json, c.addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = c.id) AS has_photo
FROM contacts c
JOIN contact_group_members m ON m.contact_id = c.id
WHERE m.group_id = ?
ORDER BY c.name ASC";
pub const Q_008: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count
FROM contact_groups g
JOIN contact_group_members m ON m.group_id = g.id
WHERE m.contact_id = ?
ORDER BY g.name ASC";
}
pub mod db_contacts {
pub const CONTACT_SELECT_COLS: &str = "id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
pub const Q_001: &str = "INSERT INTO contacts
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json";
pub const Q_002: &str = "DELETE FROM contacts WHERE id = ?";
pub const Q_003: &str = r#"
SELECT email, name
FROM known_addresses
WHERE (email LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\')
AND email NOT IN (SELECT email FROM contacts)
AND NOT EXISTS (
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
WHERE lower(alt.value) = lower(known_addresses.email)
)
ORDER BY email ASC
LIMIT ?
"#;
pub const Q_004: &str = "INSERT INTO known_addresses (email, name) VALUES (?, ?)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_005: &str = "INSERT INTO known_addresses (email, name) VALUES (?, ?)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_006: &str = "UPDATE contacts
SET contact_count = contact_count + 1,
last_contacted = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE email = ?";
pub const Q_007: &str = "INSERT IGNORE INTO contacts (id, email, name, source)
VALUES (?, ?, ?, 'auto')";
pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=CURRENT_TIMESTAMP";
pub const Q_009: &str = "SELECT content_type, data FROM contact_photos WHERE contact_id = ?";
pub const Q_010: &str = "DELETE FROM contact_photos WHERE contact_id = ?";
pub const FORMAT_001: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?";
pub const FORMAT_002: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?))";
pub const FORMAT_003: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR emails_json LIKE ? ESCAPE '\\'
ORDER BY name ASC
LIMIT ? OFFSET ?";
pub const FORMAT_004: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ? OFFSET ?";
pub const FORMAT_005: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR emails_json LIKE ? ESCAPE '\\'
ORDER BY name ASC
LIMIT ?";
}
pub mod db_display_preferences {
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?";
}
pub mod db_drafts {
pub const Q_001: &str = "INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
to_addresses = excluded.to_addresses,
cc_addresses = excluded.cc_addresses,
bcc_addresses = excluded.bcc_addresses,
subject = excluded.subject,
text_body = excluded.text_body,
html_body = excluded.html_body,
in_reply_to = excluded.in_reply_to,
references_header = excluded.references_header,
request_mdn = excluded.request_mdn,
priority = excluded.priority,
compose_format = excluded.compose_format,
pgp_mode = excluded.pgp_mode,
pgp_inline = excluded.pgp_inline,
pgp_attach_public_key = excluded.pgp_attach_public_key,
pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys,
updated_at = CURRENT_TIMESTAMP";
pub const Q_002: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts WHERE id = ?";
pub const Q_003: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts ORDER BY updated_at DESC";
pub const Q_004: &str =
"UPDATE drafts SET remote_folder = ?, remote_uid = ?, remote_uid_validity = ? WHERE id = ?";
pub const Q_005: &str = "DELETE FROM drafts WHERE id = ?";
pub const Q_006: &str =
"INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path)
VALUES (?, ?, ?, ?, ?, ?)";
pub const Q_007: &str =
"SELECT id, draft_id, filename, content_type, size, file_path, created_at
FROM draft_attachments WHERE draft_id = ? ORDER BY created_at ASC";
pub const Q_008: &str = "DELETE FROM draft_attachments WHERE id = ?";
}
pub mod db_folder_preferences {
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?";
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_003: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)";
pub const Q_004: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_005: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)";
pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES(?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = CURRENT_TIMESTAMP";
pub const Q_007: &str = "DELETE FROM folder_preferences WHERE folder_name = ?";
}
pub mod db_folders {
pub const Q_001: &str = "REPLACE INTO folders
(name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)";
pub const Q_002: &str = "INSERT IGNORE INTO folders
(name, delimiter, flags, is_subscribed, updated_at)
VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)";
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
ORDER BY name";
pub const Q_004: &str = "DELETE FROM folders";
pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP";
pub const Q_006: &str = "UPDATE folders SET uid_validity = ?, total_count = ?, messages_updated_at = CURRENT_TIMESTAMP
WHERE name = ?";
pub const Q_007: &str =
"UPDATE folders SET uid_validity = ?, total_count = ?, highest_modseq = ?
WHERE name = ?";
pub const Q_008: &str =
"SELECT COUNT(*) FROM messages WHERE folder = ? AND flags NOT LIKE '%\\Seen%'";
pub const Q_009: &str = "UPDATE folders SET unread_count = ? WHERE name = ?";
pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS SIGNED) + ?) WHERE name = ?";
pub const Q_011: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE updated_at > datetime('now', ?)
)";
pub const Q_012: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE name = ? AND messages_updated_at > datetime('now', ?)
)";
pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?";
pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?";
pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
WHERE name = ?";
pub const Q_016: &str = "DELETE FROM messages WHERE folder = ?";
pub const Q_017: &str = "DELETE FROM folders WHERE name = ?";
pub const Q_018: &str = "PRAGMA defer_foreign_keys = ON;";
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at)
SELECT ?, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, CURRENT_TIMESTAMP, messages_updated_at
FROM folders WHERE name = ?";
pub const Q_020: &str = "UPDATE messages SET folder = ? WHERE folder = ?";
pub const Q_021: &str = "UPDATE message_tags SET message_folder = ? WHERE message_folder = ?";
pub const Q_022: &str =
"UPDATE calendar_events SET source_message_folder = ? WHERE source_message_folder = ?";
pub const Q_023: &str = "UPDATE folders SET parent = ? WHERE parent = ?";
pub const Q_024: &str = "DELETE FROM folders WHERE name = ?";
pub const Q_025: &str = "SELECT name FROM folders WHERE flags LIKE CONCAT('%', ?, '%') LIMIT 1";
pub const Q_026: &str =
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'";
pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'";
pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
}
pub mod db_header_sync {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'header_sync_generation'";
pub const Q_002: &str =
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1";
}
pub mod db_identities {
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
pub const Q_002: &str = "DELETE FROM identities WHERE id = ?";
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
pub const Q_004: &str =
"UPDATE identities SET is_default = 0, updated_at = CURRENT_TIMESTAMP WHERE is_default = 1";
pub const FORMAT_001: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC";
pub const FORMAT_002: &str = "SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?";
pub const FORMAT_003: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1";
pub const FORMAT_004: &str = "UPDATE identities SET {} WHERE id = ?";
}
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_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";
}
pub mod db_messages {
pub const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header, subject, from_address, from_name, to_addresses, cc_addresses, date, flags, size, has_attachments, snippet, reaction";
pub const Q_001: &str = "REPLACE INTO messages
(uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, size, has_attachments, snippet, date_epoch, reaction)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
pub const Q_002: &str = "DELETE FROM messages WHERE folder = ?";
pub const Q_003: &str = "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?";
pub const Q_004: &str = "SELECT uid, folder, subject, from_address, from_name, to_addresses, \
COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \
FROM messages ORDER BY folder, uid";
pub const Q_005: &str = "SELECT COUNT(*) FROM messages WHERE folder = ?";
pub const Q_006: &str = "SELECT COUNT(*) FROM messages";
pub const Q_007: &str = "UPDATE messages SET flags = ? WHERE folder = ? AND uid = ?";
pub const Q_008: &str = "UPDATE messages
SET body_html = ?, body_text = ?, body_cached = 1,
attachments_json = ?, raw_headers = ?, email_theme = ?
WHERE folder = ? AND uid = ?";
pub const Q_009: &str = "UPDATE messages SET email_theme = ? WHERE folder = ? AND uid = ?";
pub const Q_010: &str =
"SELECT body_cached, body_html, body_text, attachments_json, raw_headers, email_theme
FROM messages
WHERE folder = ? AND uid = ?";
pub const Q_011: &str = "DELETE FROM messages WHERE folder = ? AND uid = ?";
pub const Q_012: &str = "SELECT folder, uid FROM messages
WHERE body_cached = 0
ORDER BY date_epoch DESC
LIMIT ?";
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?";
pub const Q_014: &str = "SELECT SUM(size) FROM messages";
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?
ORDER BY date_epoch DESC
LIMIT ? OFFSET ?";
pub const FORMAT_002: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?
ORDER BY {order_by}
LIMIT ? OFFSET ?";
pub const FORMAT_003: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ? AND uid = ?";
pub const FORMAT_004: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?
OR in_reply_to = ?
OR references_header LIKE ? ESCAPE '\\'
ORDER BY date_epoch ASC";
pub const FORMAT_005: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
{where_clause}
ORDER BY date_epoch DESC
LIMIT ?";
}
pub mod db_notification_preferences {
pub const Q_001: &str = "SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT IGNORE INTO notification_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE notification_preferences SET {set_clause} WHERE id = ?";
}
pub mod db_pgp {
pub const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at";
pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES (?,?,?,?,?,?,?,?,?)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=CURRENT_TIMESTAMP";
pub const Q_002: &str = "DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?";
pub const Q_003: &str = "UPDATE pgp_keys SET trust=?,updated_at=CURRENT_TIMESTAMP WHERE replace(upper(fingerprint),' ','')=?";
pub const Q_004: &str =
"SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1";
pub const Q_005: &str = "UPDATE pgp_preferences SET wkd_enabled=?,hkp_enabled=?,require_trusted_encryption=?,updated_at=CURRENT_TIMESTAMP WHERE id=1";
pub const FORMAT_001: &str =
"SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint";
pub const FORMAT_002: &str =
"SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?";
pub const FORMAT_003: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC";
pub const FORMAT_004: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1";
pub const FORMAT_005: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint";
}
pub mod db_pool {
pub const Q_001: &str = "SELECT 0";
pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_003: &str = "SELECT 1";
pub const Q_004: &str = "SELECT 1";
pub const Q_005: &str = "SHOW TABLES";
pub const Q_006: &str = "SELECT 1";
}
pub mod db_response_templates {
pub const Q_001: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name ";
pub const Q_002: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=?";
pub const Q_003: &str = "INSERT INTO response_templates(name,body,format) VALUES (?,?,?)";
pub const Q_004: &str = "UPDATE response_templates SET name=?,body=?,format=?,updated_at=CURRENT_TIMESTAMP WHERE id=?";
pub const Q_005: &str = "DELETE FROM response_templates WHERE id=?";
}
pub mod db_saved_searches {
pub const Q_001: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name ";
pub const Q_002: &str = "INSERT INTO saved_searches(name,query_string) VALUES (?,?)";
pub const Q_003: &str =
"UPDATE saved_searches SET name=?,query_string=?,updated_at=CURRENT_TIMESTAMP WHERE id=?";
pub const Q_004: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=?";
pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=?";
}
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 = ?)";
pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES(?, ?, CURRENT_TIMESTAMP)\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = CURRENT_TIMESTAMP";
pub const Q_004: &str = "DELETE FROM special_folders WHERE role = ?";
pub const Q_005: &str = "UPDATE special_folders SET folder_name = ?, updated_at = CURRENT_TIMESTAMP WHERE folder_name = ?";
pub const Q_006: &str = "DELETE FROM special_folders WHERE folder_name = ?";
}
pub mod db_tags {
pub const Q_001: &str = "INSERT INTO tags (id, name, color, imap_keyword) VALUES (?, ?, ?, ?)";
pub const Q_002: &str =
"SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at,
(SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count
FROM tags t
ORDER BY t.name ASC";
pub const Q_003: &str = "SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?";
pub const Q_004: &str =
"UPDATE tags SET name = ?, color = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q_005: &str = "DELETE FROM tags WHERE id = ?";
pub const Q_006: &str =
"INSERT IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES (?, ?, ?)";
pub const Q_007: &str =
"DELETE FROM message_tags WHERE tag_id = ? AND message_uid = ? AND message_folder = ?";
pub const Q_008: &str = "SELECT t.id, t.name, t.color
FROM tags t
JOIN message_tags mt ON mt.tag_id = t.id
WHERE mt.message_uid = ? AND mt.message_folder = ?
ORDER BY t.name ASC";
pub const Q_009: &str =
"SELECT m.uid, m.folder, m.message_id, m.in_reply_to, m.references_header,
m.subject, m.from_address, m.from_name, m.to_addresses, m.cc_addresses,
m.date, m.flags, m.size, m.has_attachments, m.snippet, m.reaction
FROM messages m
JOIN message_tags mt ON mt.message_uid = m.uid AND mt.message_folder = m.folder
WHERE mt.tag_id = ?
ORDER BY m.date_epoch DESC
LIMIT ? OFFSET ?";
pub const Q_010: &str = "SELECT COUNT(*) FROM message_tags WHERE tag_id = ?";
pub const Q_011: &str = "INSERT IGNORE INTO folders (name) VALUES (?)";
pub const Q_012: &str = "INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet)
VALUES (?, ?, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')";
pub const FORMAT_001: &str = "SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC";
}
pub mod db_trusted_senders {
pub const Q_001: &str = "SELECT email FROM trusted_senders ORDER BY lower(email)";
pub const Q_002: &str = "INSERT IGNORE INTO trusted_senders(email) VALUES(?)";
pub const Q_003: &str = "DELETE FROM trusted_senders WHERE email = ? ";
}
pub mod routes_contacts {
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_003: &str = "SELECT CURRENT_TIMESTAMP";
}
pub mod routes_diagnostics {
pub const Q_001: &str = "SELECT COUNT(*),
COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0),
CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE replace(MAX(updated_at), ' ', 'T') || 'Z' END,
CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE replace(MAX(messages_updated_at), ' ', 'T') || 'Z' END
FROM folders";
pub const Q_002: &str = "SELECT COUNT(*) FROM messages";
pub const Q_003: &str = "SELECT COUNT(*) FROM messages WHERE body_cached = 1";
}
pub mod routes_folder_mgmt {
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ? ELSE flags || ',' || ? END WHERE folder = ? AND instr(flags, ?) = 0";
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = ?";
pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = ?";
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = CURRENT_TIMESTAMP WHERE name = ?";
}
pub mod routes_messages_mod {
pub const Q_001: &str = "SELECT flags FROM messages WHERE folder = ? AND uid = ?";
}
pub mod routes_pgp {
pub const Q_001: &str = "UPDATE identities SET pgp_fingerprint=NULL,pgp_sign_default=0,pgp_encrypt_default=0,pgp_inline_default=0 WHERE replace(upper(COALESCE(pgp_fingerprint,'')),' ','')=?";
}
pub mod routes_special_folders {
pub const Q_001: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?)";
}
pub mod sessions {
pub const UPSERT: &str = r#"INSERT INTO sessions (
token, account_id, browser_id, email, password_ciphertext, user_hash,
imap_host, imap_port, imap_tls, imap_tls_verify, smtp_host, smtp_port,
smtp_tls, smtp_tls_verify, timeout_secs, created_at_unix, last_accessed_unix, user_agent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
account_id=VALUES(account_id), browser_id=VALUES(browser_id), email=VALUES(email),
password_ciphertext=VALUES(password_ciphertext), user_hash=VALUES(user_hash), imap_host=VALUES(imap_host),
imap_port=VALUES(imap_port), imap_tls=VALUES(imap_tls), imap_tls_verify=VALUES(imap_tls_verify),
smtp_host=VALUES(smtp_host), smtp_port=VALUES(smtp_port), smtp_tls=VALUES(smtp_tls),
smtp_tls_verify=VALUES(smtp_tls_verify), timeout_secs=VALUES(timeout_secs),
last_accessed_unix=VALUES(last_accessed_unix), user_agent=VALUES(user_agent)"#;
pub const BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = ?";
pub const BY_BROWSER: &str =
"SELECT * FROM sessions WHERE browser_id = ? ORDER BY created_at_unix";
pub const BY_USER: &str =
"SELECT * FROM sessions WHERE user_hash = ? ORDER BY last_accessed_unix DESC";
pub const BROWSER_EXISTS: &str = "SELECT COUNT(*) FROM sessions WHERE browser_id = ?";
pub const DELETE_ACCOUNT: &str = "DELETE FROM sessions WHERE account_id = ?";
pub const DELETE_BROWSER: &str = "DELETE FROM sessions WHERE browser_id = ?";
pub const DELETE_EXPIRED: &str =
"DELETE FROM sessions WHERE last_accessed_unix + timeout_secs < ?";
}
+1
View File
@@ -0,0 +1 @@
pub mod queries;
+486
View File
@@ -0,0 +1,486 @@
pub mod auth_user_data {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'schema_version'";
}
pub mod db_calendar {
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_002: &str = "INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
recurrence_rule, attendees, organizer, status, source, source_uid,
source_message_uid, source_message_folder, meeting_url, color,
reminder_minutes, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)";
pub const Q_003: &str = "DELETE FROM calendar_events WHERE id = $1";
pub const Q_004: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_005: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_006: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates ORDER BY is_default DESC, name ASC";
pub const Q_007: &str =
"INSERT INTO meeting_templates (name, url_template, icon) VALUES ($1, $2, $3)";
pub const Q_008: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates WHERE id = $1";
pub const Q_009: &str = "DELETE FROM meeting_templates WHERE id = $1";
pub const FORMAT_001: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= $1 AND start_time <= $2
ORDER BY start_time ASC";
pub const FORMAT_002: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC";
pub const FORMAT_003: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = $1";
pub const FORMAT_004: &str = "UPDATE calendar_events SET {} WHERE id = ${idx}";
pub const FORMAT_005: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = $1";
pub const FORMAT_006: &str =
"SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1";
pub const FORMAT_007: &str = "UPDATE calendar_settings SET {} WHERE id = 1";
}
pub mod db_contact_groups {
pub const Q_001: &str = "INSERT INTO contact_groups (id, name) VALUES ($1, $2)";
pub const Q_002: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count
FROM contact_groups g
ORDER BY g.name ASC";
pub const Q_003: &str =
"UPDATE contact_groups SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2";
pub const Q_004: &str = "DELETE FROM contact_groups WHERE id = $1";
pub const Q_005: &str =
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES ($1, $2)";
pub const Q_006: &str =
"DELETE FROM contact_group_members WHERE group_id = $1 AND contact_id = $2";
pub const Q_007: &str = "SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite,
c.last_contacted, c.contact_count, c.source, c.created_at, c.updated_at, c.emails_json, c.phones_json, c.addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = c.id) AS has_photo
FROM contacts c
JOIN contact_group_members m ON m.contact_id = c.id
WHERE m.group_id = $1
ORDER BY c.name ASC";
pub const Q_008: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count
FROM contact_groups g
JOIN contact_group_members m ON m.group_id = g.id
WHERE m.contact_id = $1
ORDER BY g.name ASC";
}
pub mod db_contacts {
pub const CONTACT_SELECT_COLS: &str = "id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
pub const Q_001: &str = "INSERT INTO contacts
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT(id) DO UPDATE SET
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json";
pub const Q_002: &str = "DELETE FROM contacts WHERE id = $1";
pub const Q_003: &str = r#"
SELECT email, name
FROM known_addresses
WHERE (email LIKE $1 ESCAPE '\' OR name LIKE $1 ESCAPE '\')
AND email NOT IN (SELECT email FROM contacts)
AND NOT EXISTS (
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
WHERE lower(alt.value) = lower(known_addresses.email)
)
ORDER BY email ASC
LIMIT $2
"#;
pub const Q_004: &str = "INSERT INTO known_addresses (email, name) VALUES ($1, $2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_005: &str = "INSERT INTO known_addresses (email, name) VALUES ($1, $2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_006: &str = "UPDATE contacts
SET contact_count = contact_count + 1,
last_contacted = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE email = $1";
pub const Q_007: &str = "INSERT OR IGNORE INTO contacts (id, email, name, source)
VALUES ($1, $2, $3, 'auto')";
pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=CURRENT_TIMESTAMP";
pub const Q_009: &str = "SELECT content_type, data FROM contact_photos WHERE contact_id = $1";
pub const Q_010: &str = "DELETE FROM contact_photos WHERE contact_id = $1";
pub const FORMAT_001: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = $1";
pub const FORMAT_002: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower($1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower($1))";
pub const FORMAT_003: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE $1 ESCAPE '\\' OR email LIKE $1 ESCAPE '\\' OR emails_json LIKE $1 ESCAPE '\\'
ORDER BY name ASC
LIMIT $2 OFFSET $3";
pub const FORMAT_004: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT $1 OFFSET $2";
pub const FORMAT_005: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE $1 ESCAPE '\\' OR email LIKE $1 ESCAPE '\\' OR emails_json LIKE $1 ESCAPE '\\'
ORDER BY name ASC
LIMIT $2";
}
pub mod db_display_preferences {
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ${}";
}
pub mod db_drafts {
pub const Q_001: &str = "INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
to_addresses = excluded.to_addresses,
cc_addresses = excluded.cc_addresses,
bcc_addresses = excluded.bcc_addresses,
subject = excluded.subject,
text_body = excluded.text_body,
html_body = excluded.html_body,
in_reply_to = excluded.in_reply_to,
references_header = excluded.references_header,
request_mdn = excluded.request_mdn,
priority = excluded.priority,
compose_format = excluded.compose_format,
pgp_mode = excluded.pgp_mode,
pgp_inline = excluded.pgp_inline,
pgp_attach_public_key = excluded.pgp_attach_public_key,
pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys,
updated_at = CURRENT_TIMESTAMP";
pub const Q_002: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts WHERE id = $1";
pub const Q_003: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts ORDER BY updated_at DESC";
pub const Q_004: &str = "UPDATE drafts SET remote_folder = $2, remote_uid = $3, remote_uid_validity = $4 WHERE id = $1";
pub const Q_005: &str = "DELETE FROM drafts WHERE id = $1";
pub const Q_006: &str =
"INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path)
VALUES ($1, $2, $3, $4, $5, $6)";
pub const Q_007: &str =
"SELECT id, draft_id, filename, content_type, size, file_path, created_at
FROM draft_attachments WHERE draft_id = $1 ORDER BY created_at ASC";
pub const Q_008: &str = "DELETE FROM draft_attachments WHERE id = $1";
}
pub mod db_folder_preferences {
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = $1";
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_003: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)";
pub const Q_004: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_005: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)";
pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES($1, $2, CURRENT_TIMESTAMP)\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = CURRENT_TIMESTAMP";
pub const Q_007: &str = "DELETE FROM folder_preferences WHERE folder_name = $1";
}
pub mod db_folders {
pub const Q_001: &str = "INSERT OR REPLACE INTO folders
(name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP)";
pub const Q_002: &str = "INSERT OR IGNORE INTO folders
(name, delimiter, flags, is_subscribed, updated_at)
VALUES ($1, $2, $3, 1, CURRENT_TIMESTAMP)";
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
ORDER BY name";
pub const Q_004: &str = "DELETE FROM folders";
pub const Q_005: &str = "UPDATE folders SET updated_at = CURRENT_TIMESTAMP";
pub const Q_006: &str = "UPDATE folders SET uid_validity = $1, total_count = $2, messages_updated_at = CURRENT_TIMESTAMP
WHERE name = $3";
pub const Q_007: &str =
"UPDATE folders SET uid_validity = $1, total_count = $2, highest_modseq = $3
WHERE name = $4";
pub const Q_008: &str =
"SELECT COUNT(*) FROM messages WHERE folder = $1 AND flags NOT LIKE '%\\Seen%'";
pub const Q_009: &str = "UPDATE folders SET unread_count = $1 WHERE name = $2";
pub const Q_010: &str = "UPDATE folders SET unread_count = GREATEST(0, CAST(unread_count AS INTEGER) + $1) WHERE name = $2";
pub const Q_011: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE updated_at > datetime('now', $1)
)";
pub const Q_012: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE name = $1 AND messages_updated_at > datetime('now', $2)
)";
pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = $1";
pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = $1";
pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
WHERE name = $1";
pub const Q_016: &str = "DELETE FROM messages WHERE folder = $1";
pub const Q_017: &str = "DELETE FROM folders WHERE name = $1";
pub const Q_018: &str = "PRAGMA defer_foreign_keys = ON;";
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at)
SELECT $1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, CURRENT_TIMESTAMP, messages_updated_at
FROM folders WHERE name = $2";
pub const Q_020: &str = "UPDATE messages SET folder = $1 WHERE folder = $2";
pub const Q_021: &str = "UPDATE message_tags SET message_folder = $1 WHERE message_folder = $2";
pub const Q_022: &str =
"UPDATE calendar_events SET source_message_folder = $1 WHERE source_message_folder = $2";
pub const Q_023: &str = "UPDATE folders SET parent = $1 WHERE parent = $2";
pub const Q_024: &str = "DELETE FROM folders WHERE name = $1";
pub const Q_025: &str = "SELECT name FROM folders WHERE flags LIKE '%' || $1 || '%' LIMIT 1";
pub const Q_026: &str =
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'";
pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'";
pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
}
pub mod db_header_sync {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'header_sync_generation'";
pub const Q_002: &str =
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1";
}
pub mod db_identities {
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)";
pub const Q_002: &str = "DELETE FROM identities WHERE id = $1";
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
pub const Q_004: &str =
"UPDATE identities SET is_default = 0, updated_at = CURRENT_TIMESTAMP WHERE is_default = 1";
pub const FORMAT_001: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC";
pub const FORMAT_002: &str = "SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = $1";
pub const FORMAT_003: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1";
pub const FORMAT_004: &str = "UPDATE identities SET {} WHERE id = ${}";
}
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_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 WHERE id = 1";
}
pub mod db_messages {
pub const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header, subject, from_address, from_name, to_addresses, cc_addresses, date, flags, size, has_attachments, snippet, reaction";
pub const Q_001: &str = "INSERT OR REPLACE INTO messages
(uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, size, has_attachments, snippet, date_epoch, reaction)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)";
pub const Q_002: &str = "DELETE FROM messages WHERE folder = $1";
pub const Q_003: &str = "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = $1";
pub const Q_004: &str = "SELECT uid, folder, subject, from_address, from_name, to_addresses, \
COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \
FROM messages ORDER BY folder, uid";
pub const Q_005: &str = "SELECT COUNT(*) FROM messages WHERE folder = $1";
pub const Q_006: &str = "SELECT COUNT(*) FROM messages";
pub const Q_007: &str = "UPDATE messages SET flags = $1 WHERE folder = $2 AND uid = $3";
pub const Q_008: &str = "UPDATE messages
SET body_html = $1, body_text = $2, body_cached = 1,
attachments_json = $3, raw_headers = $4, email_theme = $5
WHERE folder = $6 AND uid = $7";
pub const Q_009: &str = "UPDATE messages SET email_theme = $1 WHERE folder = $2 AND uid = $3";
pub const Q_010: &str =
"SELECT body_cached, body_html, body_text, attachments_json, raw_headers, email_theme
FROM messages
WHERE folder = $1 AND uid = $2";
pub const Q_011: &str = "DELETE FROM messages WHERE folder = $1 AND uid = $2";
pub const Q_012: &str = "SELECT folder, uid FROM messages
WHERE body_cached = 0
ORDER BY date_epoch DESC
LIMIT $1";
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = $1";
pub const Q_014: &str = "SELECT SUM(size) FROM messages";
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = $1
ORDER BY date_epoch DESC
LIMIT $2 OFFSET $3";
pub const FORMAT_002: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = $1
ORDER BY {order_by}
LIMIT $2 OFFSET $3";
pub const FORMAT_003: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = $1 AND uid = $2";
pub const FORMAT_004: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = $1
OR in_reply_to = $1
OR references_header LIKE $2 ESCAPE '\\'
ORDER BY date_epoch ASC";
pub const FORMAT_005: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
{where_clause}
ORDER BY date_epoch DESC
LIMIT ${}";
}
pub mod db_notification_preferences {
pub const Q_001: &str = "SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)";
pub const FORMAT_001: &str =
"UPDATE notification_preferences SET {set_clause} WHERE id = ${idx}";
}
pub mod db_pgp {
pub const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at";
pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=CURRENT_TIMESTAMP";
pub const Q_002: &str = "DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=$1";
pub const Q_003: &str = "UPDATE pgp_keys SET trust=$2,updated_at=CURRENT_TIMESTAMP WHERE replace(upper(fingerprint),' ','')=$1";
pub const Q_004: &str =
"SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1";
pub const Q_005: &str = "UPDATE pgp_preferences SET wkd_enabled=$1,hkp_enabled=$2,require_trusted_encryption=$3,updated_at=CURRENT_TIMESTAMP WHERE id=1";
pub const FORMAT_001: &str =
"SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint";
pub const FORMAT_002: &str =
"SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=$1";
pub const FORMAT_003: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower($1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC";
pub const FORMAT_004: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower($1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1";
pub const FORMAT_005: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint";
}
pub mod db_pool {
pub const Q_001: &str = "SELECT 0";
pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_003: &str = "SELECT 1";
pub const Q_004: &str = "SELECT 1";
pub const Q_005: &str = "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = current_schema() ORDER BY tablename";
pub const Q_006: &str = "SELECT 1";
}
pub mod db_response_templates {
pub const Q_001: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name ";
pub const Q_002: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=$1";
pub const Q_003: &str = "INSERT INTO response_templates(name,body,format) VALUES ($1,$2,$3)";
pub const Q_004: &str = "UPDATE response_templates SET name=$1,body=$2,format=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$4";
pub const Q_005: &str = "DELETE FROM response_templates WHERE id=$1";
}
pub mod db_saved_searches {
pub const Q_001: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name ";
pub const Q_002: &str = "INSERT INTO saved_searches(name,query_string) VALUES ($1,$2)";
pub const Q_003: &str = "UPDATE saved_searches SET name=$1,query_string=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$3";
pub const Q_004: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=$1";
pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=$1";
}
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)";
pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES($1, $2, CURRENT_TIMESTAMP)\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = CURRENT_TIMESTAMP";
pub const Q_004: &str = "DELETE FROM special_folders WHERE role = $1";
pub const Q_005: &str = "UPDATE special_folders SET folder_name = $1, updated_at = CURRENT_TIMESTAMP WHERE folder_name = $2";
pub const Q_006: &str = "DELETE FROM special_folders WHERE folder_name = $1";
}
pub mod db_tags {
pub const Q_001: &str =
"INSERT INTO tags (id, name, color, imap_keyword) VALUES ($1, $2, $3, $4)";
pub const Q_002: &str =
"SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at,
(SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count
FROM tags t
ORDER BY t.name ASC";
pub const Q_003: &str =
"SELECT message_uid, message_folder FROM message_tags WHERE tag_id = $1";
pub const Q_004: &str =
"UPDATE tags SET name = $1, color = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3";
pub const Q_005: &str = "DELETE FROM tags WHERE id = $1";
pub const Q_006: &str = "INSERT OR IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES ($1, $2, $3)";
pub const Q_007: &str =
"DELETE FROM message_tags WHERE tag_id = $1 AND message_uid = $2 AND message_folder = $3";
pub const Q_008: &str = "SELECT t.id, t.name, t.color
FROM tags t
JOIN message_tags mt ON mt.tag_id = t.id
WHERE mt.message_uid = $1 AND mt.message_folder = $2
ORDER BY t.name ASC";
pub const Q_009: &str =
"SELECT m.uid, m.folder, m.message_id, m.in_reply_to, m.references_header,
m.subject, m.from_address, m.from_name, m.to_addresses, m.cc_addresses,
m.date, m.flags, m.size, m.has_attachments, m.snippet, m.reaction
FROM messages m
JOIN message_tags mt ON mt.message_uid = m.uid AND mt.message_folder = m.folder
WHERE mt.tag_id = $1
ORDER BY m.date_epoch DESC
LIMIT $2 OFFSET $3";
pub const Q_010: &str = "SELECT COUNT(*) FROM message_tags WHERE tag_id = $1";
pub const Q_011: &str = "INSERT OR IGNORE INTO folders (name) VALUES ($1)";
pub const Q_012: &str = "INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet)
VALUES ($1, $2, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')";
pub const FORMAT_001: &str = "SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC";
}
pub mod db_trusted_senders {
pub const Q_001: &str = "SELECT email FROM trusted_senders ORDER BY lower(email)";
pub const Q_002: &str = "INSERT OR IGNORE INTO trusted_senders(email) VALUES($1)";
pub const Q_003: &str = "DELETE FROM trusted_senders WHERE email = $1 ";
}
pub mod routes_contacts {
pub const Q_001: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_002: &str = "SELECT CURRENT_TIMESTAMP";
pub const Q_003: &str = "SELECT CURRENT_TIMESTAMP";
}
pub mod routes_diagnostics {
pub const Q_001: &str = "SELECT COUNT(*),
COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0),
CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE replace(MAX(updated_at), ' ', 'T') || 'Z' END,
CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE replace(MAX(messages_updated_at), ' ', 'T') || 'Z' END
FROM folders";
pub const Q_002: &str = "SELECT COUNT(*) FROM messages";
pub const Q_003: &str = "SELECT COUNT(*) FROM messages WHERE body_cached = 1";
}
pub mod routes_folder_mgmt {
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN $2 ELSE flags || ',' || $2 END WHERE folder = $1 AND instr(flags, $2) = 0";
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = $1";
pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = $1";
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = CURRENT_TIMESTAMP WHERE name = $1";
}
pub mod routes_messages_mod {
pub const Q_001: &str = "SELECT flags FROM messages WHERE folder = $1 AND uid = $2";
}
pub mod routes_pgp {
pub const Q_001: &str = "UPDATE identities SET pgp_fingerprint=NULL,pgp_sign_default=0,pgp_encrypt_default=0,pgp_inline_default=0 WHERE replace(upper(COALESCE(pgp_fingerprint,'')),' ','')=$1";
}
pub mod routes_special_folders {
pub const Q_001: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = $1)";
}
pub mod sessions {
pub const UPSERT: &str = r#"INSERT INTO sessions (
token, account_id, browser_id, email, password_ciphertext, user_hash,
imap_host, imap_port, imap_tls, imap_tls_verify, smtp_host, smtp_port,
smtp_tls, smtp_tls_verify, timeout_secs, created_at_unix, last_accessed_unix, user_agent
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT(token) DO UPDATE SET
account_id=EXCLUDED.account_id, browser_id=EXCLUDED.browser_id, email=EXCLUDED.email,
password_ciphertext=EXCLUDED.password_ciphertext, user_hash=EXCLUDED.user_hash,
imap_host=EXCLUDED.imap_host, imap_port=EXCLUDED.imap_port, imap_tls=EXCLUDED.imap_tls,
imap_tls_verify=EXCLUDED.imap_tls_verify, smtp_host=EXCLUDED.smtp_host, smtp_port=EXCLUDED.smtp_port,
smtp_tls=EXCLUDED.smtp_tls, smtp_tls_verify=EXCLUDED.smtp_tls_verify, timeout_secs=EXCLUDED.timeout_secs,
last_accessed_unix=EXCLUDED.last_accessed_unix, user_agent=EXCLUDED.user_agent"#;
pub const BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = $1";
pub const BY_BROWSER: &str =
"SELECT * FROM sessions WHERE browser_id = $1 ORDER BY created_at_unix";
pub const BY_USER: &str =
"SELECT * FROM sessions WHERE user_hash = $1 ORDER BY last_accessed_unix DESC";
pub const BROWSER_EXISTS: &str = "SELECT COUNT(*) FROM sessions WHERE browser_id = $1";
pub const DELETE_ACCOUNT: &str = "DELETE FROM sessions WHERE account_id = $1";
pub const DELETE_BROWSER: &str = "DELETE FROM sessions WHERE browser_id = $1";
pub const DELETE_EXPIRED: &str =
"DELETE FROM sessions WHERE last_accessed_unix + timeout_secs < $1";
}
+1
View File
@@ -0,0 +1 @@
pub mod queries;
+747
View File
@@ -0,0 +1,747 @@
pub mod auth_user_data {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'schema_version'";
}
pub mod db_calendar {
pub const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
created_at, updated_at";
pub const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at";
pub const Q_001: &str = "SELECT datetime('now')";
pub const Q_002: &str = "INSERT INTO calendar_events
(id, title, description, location, start_time, end_time, all_day,
recurrence_rule, attendees, organizer, status, source, source_uid,
source_message_uid, source_message_folder, meeting_url, color,
reminder_minutes, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)";
pub const Q_003: &str = "DELETE FROM calendar_events WHERE id = ?1";
pub const Q_004: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_005: &str = "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)";
pub const Q_006: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates ORDER BY is_default DESC, name ASC";
pub const Q_007: &str =
"INSERT INTO meeting_templates (name, url_template, icon) VALUES (?1, ?2, ?3)";
pub const Q_008: &str = "SELECT id, name, url_template, icon, is_default, created_at
FROM meeting_templates WHERE id = ?1";
pub const Q_009: &str = "DELETE FROM meeting_templates WHERE id = ?1";
pub const FORMAT_001: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ?1 AND start_time <= ?2
ORDER BY start_time ASC";
pub const FORMAT_002: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC";
pub const FORMAT_003: &str = "SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1";
pub const FORMAT_004: &str = "UPDATE calendar_events SET {} WHERE id = ?{idx}";
pub const FORMAT_005: &str =
"SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?1";
pub const FORMAT_006: &str =
"SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1";
pub const FORMAT_007: &str = "UPDATE calendar_settings SET {} WHERE id = 1";
}
pub mod db_contact_groups {
pub const Q_001: &str = "INSERT INTO contact_groups (id, name) VALUES (?1, ?2)";
pub const Q_002: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count
FROM contact_groups g
ORDER BY g.name ASC";
pub const Q_003: &str =
"UPDATE contact_groups SET name = ?1, updated_at = datetime('now') WHERE id = ?2";
pub const Q_004: &str = "DELETE FROM contact_groups WHERE id = ?1";
pub const Q_005: &str =
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?1, ?2)";
pub const Q_006: &str =
"DELETE FROM contact_group_members WHERE group_id = ?1 AND contact_id = ?2";
pub const Q_007: &str = "SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite,
c.last_contacted, c.contact_count, c.source, c.created_at, c.updated_at, c.emails_json, c.phones_json, c.addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = c.id) AS has_photo
FROM contacts c
JOIN contact_group_members m ON m.contact_id = c.id
WHERE m.group_id = ?1
ORDER BY c.name ASC";
pub const Q_008: &str = "SELECT g.id, g.name, g.created_at, g.updated_at,
(SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count
FROM contact_groups g
JOIN contact_group_members m ON m.group_id = g.id
WHERE m.contact_id = ?1
ORDER BY g.name ASC";
}
pub mod db_contacts {
pub const CONTACT_SELECT_COLS: &str = "id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
pub const Q_001: &str = "INSERT INTO contacts
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
ON CONFLICT(id) DO UPDATE SET
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json";
pub const Q_002: &str = "DELETE FROM contacts WHERE id = ?1";
pub const Q_003: &str = r#"
SELECT email, name
FROM known_addresses
WHERE (email LIKE ?1 ESCAPE '\' OR name LIKE ?1 ESCAPE '\')
AND email NOT IN (SELECT email FROM contacts)
AND NOT EXISTS (
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
WHERE lower(alt.value) = lower(known_addresses.email)
)
ORDER BY email ASC
LIMIT ?2
"#;
pub const Q_004: &str = "INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_005: &str = "INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''";
pub const Q_006: &str = "UPDATE contacts
SET contact_count = contact_count + 1,
last_contacted = datetime('now'),
updated_at = datetime('now')
WHERE email = ?1";
pub const Q_007: &str = "INSERT OR IGNORE INTO contacts (id, email, name, source)
VALUES (?1, ?2, ?3, 'auto')";
pub const Q_008: &str = "INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=datetime('now')";
pub const Q_009: &str = "SELECT content_type, data FROM contact_photos WHERE contact_id = ?1";
pub const Q_010: &str = "DELETE FROM contact_photos WHERE contact_id = ?1";
pub const FORMAT_001: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1";
pub const FORMAT_002: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))";
pub const FORMAT_003: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2 OFFSET ?3";
pub const FORMAT_004: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ?1 OFFSET ?2";
pub const FORMAT_005: &str = "SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2";
}
pub mod db_display_preferences {
pub const Q_001: &str = "SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, interface_skin, preset_scale_unlocked, updated_at FROM display_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO display_preferences (id) VALUES (1)";
pub const FORMAT_001: &str = "UPDATE display_preferences SET {} WHERE id = ?{}";
}
pub mod db_drafts {
pub const Q_001: &str = "INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
to_addresses = excluded.to_addresses,
cc_addresses = excluded.cc_addresses,
bcc_addresses = excluded.bcc_addresses,
subject = excluded.subject,
text_body = excluded.text_body,
html_body = excluded.html_body,
in_reply_to = excluded.in_reply_to,
references_header = excluded.references_header,
request_mdn = excluded.request_mdn,
priority = excluded.priority,
compose_format = excluded.compose_format,
pgp_mode = excluded.pgp_mode,
pgp_inline = excluded.pgp_inline,
pgp_attach_public_key = excluded.pgp_attach_public_key,
pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys,
updated_at = datetime('now')";
pub const Q_002: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts WHERE id = ?1";
pub const Q_003: &str = "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
FROM drafts ORDER BY updated_at DESC";
pub const Q_004: &str = "UPDATE drafts SET remote_folder = ?2, remote_uid = ?3, remote_uid_validity = ?4 WHERE id = ?1";
pub const Q_005: &str = "DELETE FROM drafts WHERE id = ?1";
pub const Q_006: &str =
"INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)";
pub const Q_007: &str =
"SELECT id, draft_id, filename, content_type, size, file_path, created_at
FROM draft_attachments WHERE draft_id = ?1 ORDER BY created_at ASC";
pub const Q_008: &str = "DELETE FROM draft_attachments WHERE id = ?1";
}
pub mod db_folder_preferences {
pub const Q_001: &str = "SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?1";
pub const Q_002: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_003: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)";
pub const Q_004: &str = "SELECT flagged_on_top FROM mail_preferences WHERE id = 1";
pub const Q_005: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)";
pub const Q_006: &str = "INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = datetime('now')";
pub const Q_007: &str = "DELETE FROM folder_preferences WHERE folder_name = ?1";
}
pub mod db_folders {
pub const Q_001: &str = "INSERT OR REPLACE INTO folders
(name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))";
pub const Q_002: &str = "INSERT OR IGNORE INTO folders
(name, delimiter, flags, is_subscribed, updated_at)
VALUES (?1, ?2, ?3, 1, datetime('now'))";
pub const Q_003: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
ORDER BY name";
pub const Q_004: &str = "DELETE FROM folders";
pub const Q_005: &str = "UPDATE folders SET updated_at = datetime('now')";
pub const Q_006: &str = "UPDATE folders SET uid_validity = ?1, total_count = ?2, messages_updated_at = datetime('now')
WHERE name = ?3";
pub const Q_007: &str =
"UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3
WHERE name = ?4";
pub const Q_008: &str =
"SELECT COUNT(*) FROM messages WHERE folder = ?1 AND flags NOT LIKE '%\\Seen%'";
pub const Q_009: &str = "UPDATE folders SET unread_count = ?1 WHERE name = ?2";
pub const Q_010: &str = "UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2";
pub const Q_011: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE updated_at > datetime('now', ?1)
)";
pub const Q_012: &str = "SELECT EXISTS(
SELECT 1 FROM folders
WHERE name = ?1 AND messages_updated_at > datetime('now', ?2)
)";
pub const Q_013: &str = "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1";
pub const Q_014: &str = "UPDATE folders SET messages_updated_at = NULL WHERE name = ?1";
pub const Q_015: &str = "SELECT name, delimiter, parent, flags, is_subscribed,
total_count, unread_count, uid_validity, highest_modseq
FROM folders
WHERE name = ?1";
pub const Q_016: &str = "DELETE FROM messages WHERE folder = ?1";
pub const Q_017: &str = "DELETE FROM folders WHERE name = ?1";
pub const Q_018: &str = "PRAGMA defer_foreign_keys = ON;";
pub const Q_019: &str = "INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at)
SELECT ?1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, datetime('now'), messages_updated_at
FROM folders WHERE name = ?2";
pub const Q_020: &str = "UPDATE messages SET folder = ?1 WHERE folder = ?2";
pub const Q_021: &str = "UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2";
pub const Q_022: &str =
"UPDATE calendar_events SET source_message_folder = ?1 WHERE source_message_folder = ?2";
pub const Q_023: &str = "UPDATE folders SET parent = ?1 WHERE parent = ?2";
pub const Q_024: &str = "DELETE FROM folders WHERE name = ?1";
pub const Q_025: &str = "SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1";
pub const Q_026: &str =
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'";
pub const Q_027: &str = "SELECT messages_updated_at FROM folders WHERE name = 'Sent'";
pub const FORMAT_001: &str = "DELETE FROM folders WHERE name NOT IN ({})";
}
pub mod db_header_sync {
pub const Q_001: &str = "SELECT value FROM user_meta WHERE key = 'header_sync_generation'";
pub const Q_002: &str =
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1";
}
pub mod db_identities {
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)";
pub const Q_002: &str = "DELETE FROM identities WHERE id = ?1";
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
pub const Q_004: &str =
"UPDATE identities SET is_default = 0, updated_at = datetime('now') WHERE is_default = 1";
pub const FORMAT_001: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC";
pub const FORMAT_002: &str = "SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1";
pub const FORMAT_003: &str =
"SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1";
pub const FORMAT_004: &str = "UPDATE identities SET {} WHERE id = ?{}";
}
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_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";
}
pub mod db_messages {
pub const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header, subject, from_address, from_name, to_addresses, cc_addresses, date, flags, size, has_attachments, snippet, reaction";
pub const Q_001: &str = "INSERT OR REPLACE INTO messages
(uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, size, has_attachments, snippet, date_epoch, reaction)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)";
pub const Q_002: &str = "DELETE FROM messages WHERE folder = ?1";
pub const Q_003: &str = "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1";
pub const Q_004: &str = "SELECT uid, folder, subject, from_address, from_name, to_addresses, \
COALESCE(NULLIF(body_text, ''), snippet, ''), date_epoch, has_attachments \
FROM messages ORDER BY folder, uid";
pub const Q_005: &str = "SELECT COUNT(*) FROM messages WHERE folder = ?1";
pub const Q_006: &str = "SELECT COUNT(*) FROM messages";
pub const Q_007: &str = "UPDATE messages SET flags = ?1 WHERE folder = ?2 AND uid = ?3";
pub const Q_008: &str = "UPDATE messages
SET body_html = ?1, body_text = ?2, body_cached = 1,
attachments_json = ?3, raw_headers = ?4, email_theme = ?5
WHERE folder = ?6 AND uid = ?7";
pub const Q_009: &str = "UPDATE messages SET email_theme = ?1 WHERE folder = ?2 AND uid = ?3";
pub const Q_010: &str =
"SELECT body_cached, body_html, body_text, attachments_json, raw_headers, email_theme
FROM messages
WHERE folder = ?1 AND uid = ?2";
pub const Q_011: &str = "DELETE FROM messages WHERE folder = ?1 AND uid = ?2";
pub const Q_012: &str = "SELECT folder, uid FROM messages
WHERE body_cached = 0
ORDER BY date_epoch DESC
LIMIT ?1";
pub const Q_013: &str = "SELECT uid, flags FROM messages WHERE folder = ?1";
pub const Q_014: &str = "SELECT SUM(size) FROM messages";
pub const FORMAT_001: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY date_epoch DESC
LIMIT ?2 OFFSET ?3";
pub const FORMAT_002: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY {order_by}
LIMIT ?2 OFFSET ?3";
pub const FORMAT_003: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1 AND uid = ?2";
pub const FORMAT_004: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?1
OR in_reply_to = ?1
OR references_header LIKE ?2 ESCAPE '\\'
ORDER BY date_epoch ASC";
pub const FORMAT_005: &str = "SELECT {MSG_SELECT_COLS}
FROM messages
{where_clause}
ORDER BY date_epoch DESC
LIMIT ?{}";
}
pub mod db_notification_preferences {
pub const Q_001: &str = "SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)";
pub const FORMAT_001: &str =
"UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}";
}
pub mod db_pgp {
pub const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at";
pub const Q_001: &str = "INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=datetime('now')";
pub const Q_002: &str = "DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1";
pub const Q_003: &str = "UPDATE pgp_keys SET trust=?2,updated_at=datetime('now') WHERE replace(upper(fingerprint),' ','')=?1";
pub const Q_004: &str =
"SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1";
pub const Q_005: &str = "UPDATE pgp_preferences SET wkd_enabled=?1,hkp_enabled=?2,require_trusted_encryption=?3,updated_at=datetime('now') WHERE id=1";
pub const FORMAT_001: &str =
"SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint";
pub const FORMAT_002: &str =
"SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1";
pub const FORMAT_003: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC";
pub const FORMAT_004: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1";
pub const FORMAT_005: &str = "SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint";
}
pub mod db_pool {
pub const Q_001: &str = "PRAGMA user_version";
pub const Q_002: &str = "PRAGMA journal_mode=WAL;";
pub const Q_003: &str = "PRAGMA foreign_keys=ON;";
pub const Q_004: &str = "PRAGMA foreign_keys=ON;";
pub const Q_005: &str = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
pub const Q_006: &str = "PRAGMA foreign_keys";
pub fn set_user_version(version: u32) -> String {
format!("PRAGMA user_version = {version};")
}
}
pub mod db_response_templates {
pub const Q_001: &str = "SELECT id,name,body,format,created_at,updated_at FROM response_templates ORDER BY name COLLATE NOCASE";
pub const Q_002: &str =
"SELECT id,name,body,format,created_at,updated_at FROM response_templates WHERE id=?1";
pub const Q_003: &str = "INSERT INTO response_templates(name,body,format) VALUES (?1,?2,?3)";
pub const Q_004: &str = "UPDATE response_templates SET name=?1,body=?2,format=?3,updated_at=datetime('now') WHERE id=?4";
pub const Q_005: &str = "DELETE FROM response_templates WHERE id=?1";
}
pub mod db_saved_searches {
pub const Q_001: &str = "SELECT id,name,query_string,created_at,updated_at FROM saved_searches ORDER BY name COLLATE NOCASE";
pub const Q_002: &str = "INSERT INTO saved_searches(name,query_string) VALUES (?1,?2)";
pub const Q_003: &str =
"UPDATE saved_searches SET name=?1,query_string=?2,updated_at=datetime('now') WHERE id=?3";
pub const Q_004: &str =
"SELECT id,name,query_string,created_at,updated_at FROM saved_searches WHERE id=?1";
pub const Q_005: &str = "DELETE FROM saved_searches WHERE id=?1";
}
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)";
pub const Q_003: &str = "INSERT INTO special_folders(role, folder_name, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(role) DO UPDATE SET folder_name = excluded.folder_name, updated_at = datetime('now')";
pub const Q_004: &str = "DELETE FROM special_folders WHERE role = ?1";
pub const Q_005: &str = "UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2";
pub const Q_006: &str = "DELETE FROM special_folders WHERE folder_name = ?1";
}
pub mod db_tags {
pub const Q_001: &str =
"INSERT INTO tags (id, name, color, imap_keyword) VALUES (?1, ?2, ?3, ?4)";
pub const Q_002: &str =
"SELECT t.id, t.name, t.color, t.imap_keyword, t.created_at, t.updated_at,
(SELECT COUNT(*) FROM message_tags mt WHERE mt.tag_id = t.id) AS message_count
FROM tags t
ORDER BY t.name ASC";
pub const Q_003: &str =
"SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?1";
pub const Q_004: &str =
"UPDATE tags SET name = ?1, color = ?2, updated_at = datetime('now') WHERE id = ?3";
pub const Q_005: &str = "DELETE FROM tags WHERE id = ?1";
pub const Q_006: &str = "INSERT OR IGNORE INTO message_tags (tag_id, message_uid, message_folder) VALUES (?1, ?2, ?3)";
pub const Q_007: &str =
"DELETE FROM message_tags WHERE tag_id = ?1 AND message_uid = ?2 AND message_folder = ?3";
pub const Q_008: &str = "SELECT t.id, t.name, t.color
FROM tags t
JOIN message_tags mt ON mt.tag_id = t.id
WHERE mt.message_uid = ?1 AND mt.message_folder = ?2
ORDER BY t.name ASC";
pub const Q_009: &str =
"SELECT m.uid, m.folder, m.message_id, m.in_reply_to, m.references_header,
m.subject, m.from_address, m.from_name, m.to_addresses, m.cc_addresses,
m.date, m.flags, m.size, m.has_attachments, m.snippet, m.reaction
FROM messages m
JOIN message_tags mt ON mt.message_uid = m.uid AND mt.message_folder = m.folder
WHERE mt.tag_id = ?1
ORDER BY m.date_epoch DESC
LIMIT ?2 OFFSET ?3";
pub const Q_010: &str = "SELECT COUNT(*) FROM message_tags WHERE tag_id = ?1";
pub const Q_011: &str = "INSERT OR IGNORE INTO folders (name) VALUES (?1)";
pub const Q_012: &str = "INSERT INTO messages (uid, folder, subject, from_address, from_name, to_addresses, date, flags, size, has_attachments, snippet)
VALUES (?1, ?2, 'Test', 'test@ex.com', 'Test', '[]', '2024-01-01', '', 100, 0, '')";
pub const FORMAT_001: &str = "SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC";
}
pub mod db_trusted_senders {
pub const Q_001: &str = "SELECT email FROM trusted_senders ORDER BY lower(email)";
pub const Q_002: &str = "INSERT OR IGNORE INTO trusted_senders(email) VALUES(?1)";
pub const Q_003: &str = "DELETE FROM trusted_senders WHERE email = ?1 COLLATE NOCASE";
}
pub mod routes_contacts {
pub const Q_001: &str = "SELECT datetime('now')";
pub const Q_002: &str = "SELECT datetime('now')";
pub const Q_003: &str = "SELECT datetime('now')";
}
pub mod routes_diagnostics {
pub const Q_001: &str = "SELECT COUNT(*),
COALESCE(SUM(CASE WHEN messages_updated_at IS NULL THEN 1 ELSE 0 END), 0),
CASE WHEN MAX(updated_at) IS NULL THEN NULL ELSE replace(MAX(updated_at), ' ', 'T') || 'Z' END,
CASE WHEN MAX(messages_updated_at) IS NULL THEN NULL ELSE replace(MAX(messages_updated_at), ' ', 'T') || 'Z' END
FROM folders";
pub const Q_002: &str = "SELECT COUNT(*) FROM messages";
pub const Q_003: &str = "SELECT COUNT(*) FROM messages WHERE body_cached = 1";
}
pub mod routes_folder_mgmt {
pub const Q_001: &str = "UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE flags || ',' || ?2 END WHERE folder = ?1 AND instr(flags, ?2) = 0";
pub const Q_002: &str = "UPDATE folders SET unread_count = 0 WHERE name = ?1";
pub const Q_003: &str = "SELECT uid FROM messages WHERE folder = ?1";
pub const Q_004: &str = "UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = datetime('now') WHERE name = ?1";
}
pub mod routes_messages_mod {
pub const Q_001: &str = "SELECT flags FROM messages WHERE folder = ?1 AND uid = ?2";
}
pub mod routes_pgp {
pub const Q_001: &str = "UPDATE identities SET pgp_fingerprint=NULL,pgp_sign_default=0,pgp_encrypt_default=0,pgp_inline_default=0 WHERE replace(upper(COALESCE(pgp_fingerprint,'')),' ','')=?1";
}
pub mod routes_special_folders {
pub const Q_001: &str = "SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)";
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_001 {
() => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ?1 AND start_time <= ?2
ORDER BY start_time ASC") };
($($args:tt)+) => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events
WHERE end_time >= ?1 AND start_time <= ?2
ORDER BY start_time ASC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_002 {
() => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC") };
($($args:tt)+) => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_003 {
() => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1") };
($($args:tt)+) => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_004 {
($set_clause:expr, $id_param_idx:expr) => {
format!(
"UPDATE calendar_events SET {} WHERE id = ?{}",
$set_clause, $id_param_idx
)
};
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_005 {
() => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?1") };
($($args:tt)+) => { format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_006 {
() => { format!("SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1") };
($($args:tt)+) => { format!("SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_calendar_007 {
() => { format!("UPDATE calendar_settings SET {} WHERE id = 1") };
($($args:tt)+) => { format!("UPDATE calendar_settings SET {} WHERE id = 1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_001 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_002 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts \
WHERE lower(email) = lower(?1) \
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_003 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2 OFFSET ?3") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2 OFFSET ?3", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_004 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ?1 OFFSET ?2") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
ORDER BY name ASC
LIMIT ?1 OFFSET ?2", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_contacts_005 {
() => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2") };
($($args:tt)+) => { format!("SELECT {CONTACT_SELECT_COLS} FROM contacts
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
ORDER BY name ASC
LIMIT ?2", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_display_preferences_001 {
() => { format!("UPDATE display_preferences SET {} WHERE id = ?{}") };
($($args:tt)+) => { format!("UPDATE display_preferences SET {} WHERE id = ?{}", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_folders_001 {
() => { format!("DELETE FROM folders WHERE name NOT IN ({})") };
($($args:tt)+) => { format!("DELETE FROM folders WHERE name NOT IN ({})", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_identities_001 {
() => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC") };
($($args:tt)+) => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_identities_002 {
() => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1") };
($($args:tt)+) => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_identities_003 {
() => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1") };
($($args:tt)+) => { format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_identities_004 {
() => { format!("UPDATE identities SET {} WHERE id = ?{}") };
($($args:tt)+) => { format!("UPDATE identities SET {} WHERE id = ?{}", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_messages_001 {
() => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY date_epoch DESC
LIMIT ?2 OFFSET ?3") };
($($args:tt)+) => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY date_epoch DESC
LIMIT ?2 OFFSET ?3", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_messages_002 {
($order_by:expr) => {
format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1
ORDER BY {}
LIMIT ?2 OFFSET ?3",
$order_by
)
};
}
#[macro_export]
macro_rules! rustbox_query_db_messages_003 {
() => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1 AND uid = ?2") };
($($args:tt)+) => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE folder = ?1 AND uid = ?2", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_messages_004 {
() => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?1
OR in_reply_to = ?1
OR references_header LIKE ?2 ESCAPE '\\'
ORDER BY date_epoch ASC") };
($($args:tt)+) => { format!("SELECT {MSG_SELECT_COLS}
FROM messages
WHERE message_id = ?1
OR in_reply_to = ?1
OR references_header LIKE ?2 ESCAPE '\\'
ORDER BY date_epoch ASC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_messages_005 {
($where_clause:expr, $limit_param_idx:expr) => {
format!(
"SELECT {MSG_SELECT_COLS}
FROM messages
{}
ORDER BY date_epoch DESC
LIMIT ?{}",
$where_clause, $limit_param_idx
)
};
}
#[macro_export]
macro_rules! rustbox_query_db_notification_preferences_001 {
($set_clause:expr, $id_param_idx:expr) => {
format!(
"UPDATE notification_preferences SET {} WHERE id = ?{}",
$set_clause, $id_param_idx
)
};
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_001 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_002 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_003 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_004 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_pgp_005 {
() => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint") };
($($args:tt)+) => { format!("SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint", $($args)+) };
}
#[macro_export]
macro_rules! rustbox_query_db_tags_001 {
() => { format!("SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC") };
($($args:tt)+) => { format!("SELECT mt.message_uid, mt.message_folder, t.id, t.name, t.color
FROM message_tags mt
JOIN tags t ON t.id = mt.tag_id
WHERE {}
ORDER BY t.name ASC", $($args)+) };
}
pub mod sessions {
pub const UPSERT: &str = r#"INSERT INTO sessions (
token, account_id, browser_id, email, password_ciphertext, user_hash,
imap_host, imap_port, imap_tls, imap_tls_verify, smtp_host, smtp_port,
smtp_tls, smtp_tls_verify, timeout_secs, created_at_unix, last_accessed_unix, user_agent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(token) DO UPDATE SET
account_id=excluded.account_id, browser_id=excluded.browser_id, email=excluded.email,
password_ciphertext=excluded.password_ciphertext, user_hash=excluded.user_hash,
imap_host=excluded.imap_host, imap_port=excluded.imap_port, imap_tls=excluded.imap_tls,
imap_tls_verify=excluded.imap_tls_verify, smtp_host=excluded.smtp_host, smtp_port=excluded.smtp_port,
smtp_tls=excluded.smtp_tls, smtp_tls_verify=excluded.smtp_tls_verify, timeout_secs=excluded.timeout_secs,
last_accessed_unix=excluded.last_accessed_unix, user_agent=excluded.user_agent"#;
pub const BY_TOKEN: &str = "SELECT * FROM sessions WHERE token = ?";
pub const BY_BROWSER: &str =
"SELECT * FROM sessions WHERE browser_id = ? ORDER BY created_at_unix";
pub const BY_USER: &str =
"SELECT * FROM sessions WHERE user_hash = ? ORDER BY last_accessed_unix DESC";
pub const BROWSER_EXISTS: &str = "SELECT COUNT(*) FROM sessions WHERE browser_id = ?";
pub const DELETE_ACCOUNT: &str = "DELETE FROM sessions WHERE account_id = ?";
pub const DELETE_BROWSER: &str = "DELETE FROM sessions WHERE browser_id = ?";
pub const DELETE_EXPIRED: &str =
"DELETE FROM sessions WHERE last_accessed_unix + timeout_secs < ?";
}
+3 -3
View File
@@ -58,12 +58,12 @@ impl EventBus {
pub async fn cleanup(&self, user_hash: &str) {
let mut channels = self.channels.write().await;
if let Some(tx) = channels.get(user_hash) {
if tx.receiver_count() == 0 {
if let Some(tx) = channels.get(user_hash)
&& tx.receiver_count() == 0
{
channels.remove(user_hash);
}
}
}
}
impl Default for EventBus {
+5 -6
View File
@@ -368,13 +368,12 @@ async fn fetch_new_after_idle(
sorted.sort();
let flags_csv = sorted.join(",");
if db::messages::update_message_flags(&conn, folder, *uid, &flags_csv).is_ok() {
if sync_keywords {
if let Err(error) =
if sync_keywords
&& let Err(error) =
db::tags::import_imap_keywords(&conn, *uid, folder, &sorted)
{
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
}
}
flags_updated += 1;
}
}
@@ -778,11 +777,11 @@ async fn fetch_and_store_headers(
) {
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to populate known addresses");
}
if sync_keywords {
if let Err(error) = db::tags::import_imap_keywords(&conn, uid, folder, &flags) {
if sync_keywords
&& let Err(error) = db::tags::import_imap_keywords(&conn, uid, folder, &flags)
{
tracing::debug!(uid = uid, error = %error, "IDLE keyword import failed");
}
}
new_count += 1;
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ pub async fn ws_handler(
.into_response();
};
let accounts = store.get_browser_accounts(&browser_id);
let accounts = store.get_browser_accounts_shared(&browser_id).await;
if accounts.is_empty() {
return (axum::http::StatusCode::UNAUTHORIZED, "No active accounts").into_response();
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more