v0.0.5
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:06:44 +01:00
1 parent 96a32ea8fb
commit a643e7e3ab
106 files changed
+2223 -4669

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# RustBox changelog # RustBox changelog
### 0.0.5
- Fixed the mobile reader toolbar regression so compact controls keep their original height and Message details / Move-copy menus open next to the triggering control instead of at the bottom of the viewport.
- Full-screen message reading now overrides the normal reader width limit so the message workspace uses the available screen width.
- Fixed mobile reader actions, attachment layout, long subjects, and touch targets.
- Added WebSocket application heartbeat, dead-connection detection, exponential reconnect, reconnect resync, event deduplication, and fallback polling.
- Fixed live message refresh coalescing so folder updates cannot suppress a pending new-message refresh.
- Improved new-mail notifications and made notification clicks open the target message when its UID is available.
- Added message full-screen mode from the reader button and context menu with Escape, browser history, deep links, and scroll restoration.
- Kept attachment upload progress only in the composer progress bar and added duplicate/cancellation safeguards.
- Removed source-code comments and added 0.0.5 regression contracts.
- Bumped the application/source release to `0.0.5`.
### 0.0.4 ### 0.0.4
- Changed attachment left-click behavior to prefer in-app preview for supported files while keeping direct download for non-previewable formats. - Changed attachment left-click behavior to prefer in-app preview for supported files while keeping direct download for non-previewable formats.
- Added attachment right-click actions for explicit Preview and Download. - Added attachment right-click actions for explicit Preview and Download.
Generated
+1 -1
View File
@@ -2957,7 +2957,7 @@ dependencies = [
[[package]] [[package]]
name = "rustbox" name = "rustbox"
version = "0.0.4" version = "0.0.5"
dependencies = [ dependencies = [
"async-imap", "async-imap",
"async-native-tls", "async-native-tls",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustbox" name = "rustbox"
version = "0.0.4" version = "0.0.5"
edition = "2024" edition = "2024"
rust-version = "1.88" rust-version = "1.88"
default-run = "rustbox" default-run = "rustbox"
+1 -1
View File
@@ -1,6 +1,6 @@
# RustBox attribution / notice # RustBox attribution / notice
RustBox 0.0.4 is derived from the Oxi webmail project: RustBox 0.0.5 is derived from the Oxi webmail project:
- Upstream: `c0h1b4/oxi` - Upstream: `c0h1b4/oxi`
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
+2 -5
View File
@@ -1,4 +1,4 @@
# RustBox 0.0.4 # RustBox 0.0.5
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`. RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
@@ -71,9 +71,6 @@ If the mail host settings are omitted, RustBox uses `localhost` with IMAP `993`
Mail-size limits are intentionally separate. `max_attachment_size_mb` applies to each raw uploaded file, while `max_message_size_mb` is checked against the final RFC822/MIME byte stream immediately before SMTP submission. The latter therefore includes message bodies, headers, MIME boundaries, base64 expansion, inline CID parts and OpenPGP transformations. The composer displays only an estimate; the server-side final-MIME check is authoritative. Mail-size limits are intentionally separate. `max_attachment_size_mb` applies to each raw uploaded file, while `max_message_size_mb` is checked against the final RFC822/MIME byte stream immediately before SMTP submission. The latter therefore includes message bodies, headers, MIME boundaries, base64 expansion, inline CID parts and OpenPGP transformations. The composer displays only an estimate; the server-side final-MIME check is authoritative.
## Current 0.0.4 UI snapshot
The internal baseline includes persistent appearance/layout preferences, draggable mail-pane splitters, action toasts, recipient autocomplete, attachment preview/context actions and client-side ZIP download in addition to the existing IMAP/Sieve/mail workflow. The authoritative completion scope remains `docs/RUSTBOX_PLAN.md`; incomplete items are tracked in `docs/IMPLEMENTATION_STATUS.md`.
## Development ## Development
@@ -141,7 +138,7 @@ Initialize Git after extracting the release archive:
```bash ```bash
git init git init
git add . git add .
git commit -m "RustBox 0.0.4 release" git commit -m "RustBox 0.0.5 release"
./scripts/setup-upstream.sh ./scripts/setup-upstream.sh
``` ```
+11 -2
View File
@@ -1,4 +1,4 @@
RustBox 0.0.4 source release RustBox 0.0.5 source release
Runtime/source model: Runtime/source model:
- one root Cargo package - one root Cargo package
@@ -19,7 +19,7 @@ Oxi tracking:
- Oxi frontend changes are reviewed and manually ported to src/web/ - Oxi frontend changes are reviewed and manually ported to src/web/
- docs/UI.md documents the browser UI/build conventions - docs/UI.md documents the browser UI/build conventions
Current 0.0.4 release additions: Current 0.0.5 release additions:
- attachment left-click defaults to safe in-app preview for supported types - attachment left-click defaults to safe in-app preview for supported types
- attachment right-click exposes explicit Preview and Download actions - attachment right-click exposes explicit Preview and Download actions
- oversized preview attempts offer direct download instead of ending with a warning - oversized preview attempts offer direct download instead of ending with a warning
@@ -64,3 +64,12 @@ Current 0.0.4 release additions:
Validation note: Validation note:
- JavaScript, JSON, TOML, SVG, shell syntax, UI selector/icon integrity and SQLite migrations were validated in the release environment. - 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. - 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.
0.0.5 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
- notification click-through to the newest message when UID is available
- full-screen message reader with context-menu entry, Escape, history and deep links
- composer-only upload progress with duplicate and cancellation safeguards
- source-code comment removal and regression contracts
+7 -26
View File
@@ -14,18 +14,11 @@ fn fnv1a64(parts: &[&[u8]]) -> u64 {
hash hash
} }
fn bundle_manifest( fn bundle_manifest(root: &Path, directory: &Path, manifest: &Path, extension: &str) -> String {
root: &Path,
directory: &Path,
manifest: &Path,
extension: &str,
banner: &str,
) -> String {
println!("cargo:rerun-if-changed={}", manifest.display()); println!("cargo:rerun-if-changed={}", manifest.display());
let manifest_text = fs::read_to_string(manifest) let manifest_text = fs::read_to_string(manifest)
.unwrap_or_else(|error| panic!("{}: {error}", manifest.display())); .unwrap_or_else(|error| panic!("{}: {error}", manifest.display()));
let mut bundle = String::from(banner); let mut bundle = String::new();
bundle.push('\n');
for raw in manifest_text.lines() { for raw in manifest_text.lines() {
let name = raw.trim(); let name = raw.trim();
@@ -48,9 +41,9 @@ fn bundle_manifest(
println!("cargo:rerun-if-changed={}", path.display()); println!("cargo:rerun-if-changed={}", path.display());
let source = let source =
fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
bundle.push_str("\n/* ---- "); if !bundle.is_empty() {
bundle.push_str(name); bundle.push('\n');
bundle.push_str(" ---- */\n"); }
bundle.push_str(&source); bundle.push_str(&source);
if !source.ends_with('\n') { if !source.ends_with('\n') {
bundle.push('\n'); bundle.push('\n');
@@ -75,20 +68,8 @@ fn main() {
println!("cargo:rerun-if-changed={}", path.display()); println!("cargo:rerun-if-changed={}", path.display());
} }
let css_bundle = bundle_manifest( let css_bundle = bundle_manifest(&root, &css_dir, &css_dir.join("manifest.txt"), ".css");
&root, let js_bundle = bundle_manifest(&root, &js_dir, &js_dir.join("manifest.txt"), ".js");
&css_dir,
&css_dir.join("manifest.txt"),
".css",
"/* RustBox UI CSS bundle - generated by build.rs */",
);
let js_bundle = bundle_manifest(
&root,
&js_dir,
&js_dir.join("manifest.txt"),
".js",
"/* RustBox UI JavaScript bundle - generated by build.rs; edit src/web/js/*.js */",
);
let asset_id = fnv1a64(&[css_bundle.as_bytes(), js_bundle.as_bytes()]); let asset_id = fnv1a64(&[css_bundle.as_bytes(), js_bundle.as_bytes()]);
println!("cargo:rustc-env=RUSTBOX_ASSET_ID={asset_id:016x}"); println!("cargo:rustc-env=RUSTBOX_ASSET_ID={asset_id:016x}");
-1
View File
@@ -27,4 +27,3 @@ sieve_allow_insecure_auth = false
sieve_timeout_secs = 10 sieve_timeout_secs = 10
trusted_proxies = "127.0.0.1,::1" trusted_proxies = "127.0.0.1,::1"
+2 -2
View File
@@ -1,4 +1,4 @@
# RustBox 0.0.4 # RustBox 0.0.5
host = "127.0.0.1" host = "127.0.0.1"
port = 3001 port = 3001
environment = "production" environment = "production"
@@ -7,7 +7,7 @@ default_theme = "system"
# Global interface style used by the login screen and by accounts set to # Global interface style used by the login screen and by accounts set to
# "Global default". Presets include gmail, proton, zoho, outlook, fastmail, # "Global default". Presets include gmail, proton, zoho, outlook, fastmail,
# apple-mail, yahoo, hey, nord, minimal and Roundcube variants. # apple-mail, yahoo, hey, nord, minimal and Roundcube variants.
default_interface_skin = "rustbox" default_interface_skin = "gmail"
data_dir = "/var/lib/rustbox" data_dir = "/var/lib/rustbox"
session_timeout_hours = 24 session_timeout_hours = 24
remember_me_enabled = true remember_me_enabled = true
-107
View File
@@ -1,107 +0,0 @@
# RustBox 0.0.4 — remaining roadmap gaps
Snapshot after the OpenPGP/key-lifecycle and unbounded Sieve action/condition work in this iteration.
## OpenPGP status
OpenPGP is functionally complete for the scope defined in `RUSTBOX_PLAN.md` §9:
- generate/import/export/delete public and private keys;
- per-user protected keyring and identity assignment;
- passphrase validation and passphrase change (GnuPG is used only for secret-packet re-wrapping; passphrases are sent via stdin/status-fd, never argv or database);
- sign, encrypt, sign+encrypt, PGP/MIME and inline OpenPGP;
- verify/decrypt with signer fingerprint, trust and invalid-signature warning;
- recipient-key checks, public-key attachment, WKD and optional HTTPS HKP discovery;
- private-key filesystem isolation and export policy.
`RUSTBOX_PGP_GPG_BINARY` may override the `gpg` executable used by the passphrase-change operation. Other cryptographic operations use rPGP directly.
## Remaining functional items — 45
### Advanced IMAP / shared folders / ACL — 11
- [ ] Namespace/shared/global folder browser driven by NAMESPACE.
- [ ] GETACL read path.
- [ ] SETACL rights editor.
- [ ] DELETEACL entry removal.
- [ ] Inherited/default rights presentation where the server exposes it.
- [ ] Clear own/shared/global folder distinction in the folder tree.
- [ ] Full production QRESYNC resynchronization path.
- [ ] Server-side SORT when advertised.
- [ ] Server-side THREAD when advertised.
- [ ] LIST-EXTENDED production behavior when advertised.
- [ ] Complete UTF8/UTF8=ACCEPT behavior across mailbox/message edge cases.
### Contacts / calendars — 12
- [ ] CardDAV discovery.
- [ ] Multiple CardDAV address books.
- [ ] CardDAV sync-token + ETag synchronization.
- [ ] CardDAV conflict/retry/delete synchronization.
- [ ] LDAP address-book adapter (RO/RW policy-driven).
- [ ] CalDAV discovery.
- [ ] Multiple and shared CalDAV calendars.
- [ ] CalDAV sync-token + ETag synchronization.
- [ ] Recurrence expansion, exceptions and edit semantics.
- [ ] Full timezone correctness behavior.
- [ ] iTIP invitation parsing/delivery workflow.
- [ ] Accept / Decline / Tentative invitation responses.
### Core mail / composer / Sieve — 7
- [ ] Full SMTP DSN negotiation.
- [ ] TNEF / `winmail.dat` decoding.
- [ ] Complete identity/signature default policy for every new/reply/forward edge case.
- [ ] Persistent multi-step Undo history beyond the current safe single MOVE undo.
- [ ] Arbitrarily branching visual Sieve groups, e.g. `(A AND (B OR C))`; the builder now supports an unlimited left-nested AND/OR chain.
- [ ] Full capability-aware Sieve condition/action matrix for all advertised extensions.
- [ ] Visual editing of existing non-RustBox/raw Sieve rules without falling back to raw text.
### Spam / password / account integration — 11
- [ ] rspamd HTTP spam adapter.
- [ ] SpamAssassin adapter.
- [ ] Custom spam-command adapter.
- [ ] Password-change API adapter.
- [ ] Password-change LDAP adapter.
- [ ] Password-change SQL adapter.
- [ ] Password-change PAM/command adapter.
- [ ] Persistent session inventory/device metadata across process restarts.
- [ ] Administrator-facing autologout/remember-me policy controls.
- [ ] New-user/onboarding identity workflow.
- [ ] Optional login → mailbox/address mapping (virtuser equivalent).
### Search / notifications / diagnostics — 4
- [ ] Complete attachment content/metadata indexing policy and limits.
- [ ] Background/service-worker notifications while the page is inactive, if retained for 0.0.4.
- [ ] Richer per-account notification policy beyond global/per-folder controls.
- [ ] Background task/queue status plus recent-error/repair workflows.
## UI / i18n work still required (not counted as separate functional features)
- Final Settings information architecture: General, Message view, separate Signatures, Autoresponder, Contacts, Calendar, Encryption, Privacy, Security and Sessions.
- Complete keyboard-only/focus-order/accessibility audit.
- Remaining hard-coded EN/PL strings moved to locale files.
- Missing-key detection/fallback audit.
- Locale-aware date/time/number formatting audit.
- Documentation/structure for additional languages.
## Release/security/acceptance work (not counted in the 45 functional items)
- complete HTML/CSS/SVG sanitizer regression matrix;
- tracking-pixel/data-URI/CID matrix;
- malformed/nested MIME and huge-message tests;
- CRLF/header injection, CSRF, XSS and cross-account authorization matrix;
- session fixation/hijacking and secure-cookie verification;
- archive/ZIP-bomb and dangerous-filename tests;
- temporary-storage cleanup verification;
- OpenPGP key-isolation/passphrase/PGP-MIME regression matrix;
- `cargo fmt --check`, `cargo clippy --all-targets`, `cargo test`, release build;
- Dovecot/Postfix/ManageSieve real-server matrix;
- SMTPUTF8/IDN real-server compatibility;
- large mailbox/large attachment/performance tests;
- long-running IDLE/reconnect storm/UIDVALIDITY/index-consistency tests;
- memory stability and restart/resume tests.
OpenPGP is no longer a functional blocker. The largest remaining feature blockers are shared folders/ACL, CardDAV/CalDAV+iTIP and integration adapters. The release remains blocked by the real Rust/server/security/performance gate until those tests are executed successfully.
-20
View File
@@ -1,20 +0,0 @@
# Roundcube skin validation
This snapshot maps RustBox components onto the official Roundcube Classic and Elastic visual tokens and adds a Larry-compatible fixed interface style.
## Source mapping
- **Classic Light**: `skins/classic/common.css` and Classic mail styles: `#F6F6F6` page background, `#F2F2F2` box background, `#CC3333` selected message/accent, `#929292` selected folder, square controls, Lucida/Verdana/Arial stack.
- **Classic Dark**: upstream Classic has no official dark mode. RustBox preserves the Classic red/gray hierarchy, square controls and gradients while adapting surfaces/text for dark contrast.
- **Elastic Light**: `skins/elastic/styles/colors.less` and variables: `#37beff` main, `#00acff` link/accent, `#f4f4f4` header, `#ddd` borders, white list/content surfaces.
- **Elastic Dark**: upstream dark tokens: `#21292c` background, `#c5d1d3` text, `#4d6066` border, `#374549` selected row, `#2f3a3f` dark header/task surface.
## Automated checks
`python3 tests/roundcube_skin_contract.py`
The test verifies all migrations on a blank SQLite database, `interface_skin`, saved-response `format`, unique HTML ids, SVG back-arrow markup, both skin/theme selector pairs, upstream palette tokens, CSS parseability and frontend/backend registration.
`build.rs` consumes `src/web/js/manifest.txt` during Cargo builds. For a standalone syntax check, concatenate the manifest entries in order and run `node --check` on the resulting `.js` file.
The browser fixture in `tests/theme_fixture.html` is rendered with the real bundled CSS. Validation screenshots in `tests/theme-shots/` cover Classic Light/Dark, Elastic Light/Dark and both mobile drawer variants.
-1357
View File
File diff suppressed because it is too large. Load diff
-26
View File
@@ -1,26 +0,0 @@
# RustBox 0.0.1
Initial development release of RustBox based on Oxi.
## Repository/runtime
- One Cargo package at repository root.
- Application source in `src/`.
- RustBox-owned browser UI in `src/web/`, embedded into the executable.
- No TypeScript, React, Next.js, Node.js or Bun build step.
- Development starts with `./dev.sh` or ordinary `cargo run`.
## Mail and UI
- Existing Oxi-derived IMAP/SMTP, synchronization, search, contacts/calendar and related backend functionality retained as the starting base.
- New compact three-pane RustBox interface with dark/light/system appearance, density/preview/scale preferences, threaded reading, HTML compose formatting and SVG-only action icons.
- IMAP/SMTP endpoints are configured only by the administrator; login accepts user email and password.
- Mail operations use the resolved connection profile stored in the authenticated session, fixing the Inbox 503 path where Sieve could work while mail routes considered the server unconfigured.
- ManageSieve integration with STARTTLS, implicit TLS and explicitly opt-in insecure transport.
## Oxi follow-up
- `UPSTREAM_BASE` records the consumed Oxi revision.
- Oxi's `backend/...` changes can be mapped to RustBox's flat repository paths using `scripts/sync-upstream.sh`.
- Individual Oxi backend commits can be ported with `scripts/oxi-port-commit.sh`.
- Oxi frontend sources are excluded; useful UI behavior is reimplemented in `src/web/`.
-21
View File
@@ -1,21 +0,0 @@
# RustBox 0.0.2
RustBox 0.0.2 modularizes the browser JavaScript build and restructures appearance selection around fixed interface styles plus a fully customizable RustBox mode.
## UI build
- Source JavaScript lives in `src/web/js/` and is ordered by `src/web/js/manifest.txt`.
- Cargo `build.rs` concatenates the modules into `rustbox-ui.js`, fingerprints the combined CSS/JS asset set and embeds the generated bundle.
- `src/web/` is the single UI source; no duplicate `ui/` snapshot or standalone bundler script is kept in the repository.
## Interface styles
- RustBox (custom)
- Roundcube Classic
- Roundcube Larry
- Roundcube Elastic
- Clean
- Graphite
- High contrast
The removed Compact/Balanced/Comfortable/Reading preset cards are no longer part of Appearance. Fixed styles use their own geometry and palette without destroying the stored RustBox custom values.
-14
View File
@@ -1,14 +0,0 @@
# RustBox 0.0.3
RustBox 0.0.3 is a mobile UI and appearance-focused maintenance release.
## Highlights
- Mobile settings and dialogs no longer grow wider than the viewport; source, header and calendar cards fill their dialog without transparent side strips.
- Calendar controls wrap into a touch-friendly grid on narrow screens.
- The visual Sieve editor keeps checkboxes next to their labels and switches complex rule rows to one column on mobile.
- Message recipient details can be expanded by tap or keyboard to reveal full addresses.
- IMAP storage quota is visible in the main folder sidebar above the IMAP/version footer.
- `default_theme = "system" | "dark" | "light"` controls the global pre-login/login theme from TOML.
- New interface profiles: Gmail inspired, Proton inspired, Zoho inspired, Outlook inspired and Minimal.
- Polish localization now covers substantially more settings, dialogs, filters, calendar and contact UI.
-11
View File
@@ -1,11 +0,0 @@
# RustBox 0.0.4
RustBox 0.0.4 improves attachment interaction in the message reader.
## Highlights
- Left-click previews supported attachment types in the in-app viewer.
- Right-click exposes explicit **Preview** and **Download** actions.
- Unsupported attachment types still download directly on left-click.
- Attachments that exceed the preview limit now offer **Download** instead of only showing a warning.
- Known oversized attachments are rejected before fetching their full payload for preview.
-3
View File
@@ -45,9 +45,6 @@ tmp_mapped="$(mktemp)"
cleanup() { rm -f "$tmp_raw" "$tmp_mapped"; } cleanup() { rm -f "$tmp_raw" "$tmp_mapped"; }
trap cleanup EXIT trap cleanup EXIT
# RustBox flattened Oxi's backend/ crate into the repository root. Generate
# only the upstream backend delta, then rewrite patch metadata from
# backend/<path> to <path>. File contents are not rewritten.
git diff --binary --find-renames "$BASE..$TARGET_SHA" -- backend/ > "$tmp_raw" git diff --binary --find-renames "$BASE..$TARGET_SHA" -- backend/ > "$tmp_raw"
sed \ sed \
-e 's#^diff --git a/backend/#diff --git a/#' \ -e 's#^diff --git a/backend/#diff --git a/#' \
+5 -26
View File
@@ -3,24 +3,9 @@ use axum::http::{Method, StatusCode};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
/// Methods that mutate state and therefore require CSRF protection. const STATE_CHANGING_METHODS: [Method; 4] =
const STATE_CHANGING_METHODS: [Method; 4] = [ [Method::POST, Method::PUT, Method::DELETE, Method::PATCH];
Method::POST,
Method::PUT,
Method::DELETE,
Method::PATCH,
];
/// Axum middleware that enforces CSRF protection on state-changing requests.
///
/// For POST, PUT, DELETE, and PATCH requests the `X-Requested-With` header
/// must be present (any non-empty value is accepted). GET, HEAD, OPTIONS, and
/// all other safe methods pass through without the check.
///
/// This works in concert with `SameSite=Strict` session cookies: the cookie
/// attribute prevents the browser from sending credentials on cross-origin
/// requests, while this header check blocks cross-origin form submissions
/// (browsers do not allow custom headers in simple/form requests).
pub async fn csrf_protection(request: Request, next: Next) -> Response { pub async fn csrf_protection(request: Request, next: Next) -> Response {
if STATE_CHANGING_METHODS.contains(request.method()) if STATE_CHANGING_METHODS.contains(request.method())
&& !request.headers().contains_key("x-requested-with") && !request.headers().contains_key("x-requested-with")
@@ -46,24 +31,19 @@ pub async fn csrf_protection(request: Request, next: Next) -> Response {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use axum::Router;
use axum::body::Body; use axum::body::Body;
use axum::routing::get; use axum::routing::get;
use axum::Router;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use tower::ServiceExt; use tower::ServiceExt;
/// Build a minimal router with CSRF middleware applied.
fn app() -> Router { fn app() -> Router {
Router::new() Router::new()
.route("/test", get(|| async { "ok" }).post(|| async { "ok" })) .route("/test", get(|| async { "ok" }).post(|| async { "ok" }))
.layer(axum::middleware::from_fn(csrf_protection)) .layer(axum::middleware::from_fn(csrf_protection))
} }
/// Helper: send a request and return the status code and parsed JSON body. async fn send(method: Method, with_header: bool) -> (StatusCode, serde_json::Value) {
async fn send(
method: Method,
with_header: bool,
) -> (StatusCode, serde_json::Value) {
let mut builder = Request::builder().method(method).uri("/test"); let mut builder = Request::builder().method(method).uri("/test");
if with_header { if with_header {
@@ -182,8 +162,7 @@ mod tests {
.unwrap(); .unwrap();
let response = app.oneshot(request).await.unwrap(); let response = app.oneshot(request).await.unwrap();
// OPTIONS may return 200 or other codes depending on router config,
// but it must NOT return 403 from CSRF middleware.
assert_ne!(response.status(), StatusCode::FORBIDDEN); assert_ne!(response.status(), StatusCode::FORBIDDEN);
} }
} }
+13 -35
View File
@@ -1,23 +1,14 @@
use async_imap::error::Error as ImapError; use async_imap::error::Error as ImapError;
/// Result of validating credentials against an IMAP server.
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum AuthResult { pub enum AuthResult {
/// The credentials are valid; login succeeded (and we immediately logged out).
Success, Success,
/// The server rejected the credentials (wrong email or password).
InvalidCredentials, InvalidCredentials,
/// Could not reach or communicate with the IMAP server.
ServerUnreachable(String), ServerUnreachable(String),
} }
/// Validate an email/password pair against a real IMAP server.
///
/// Connects to the given `host:port`, optionally using TLS, attempts a LOGIN
/// command with the provided credentials, and immediately logs out on success.
///
/// This function does **not** maintain a persistent IMAP connection; it is
/// intended solely for credential validation at login time.
pub async fn validate_imap_credentials( pub async fn validate_imap_credentials(
host: &str, host: &str,
port: u16, port: u16,
@@ -33,9 +24,13 @@ pub async fn validate_imap_credentials(
} }
} }
/// TLS path: connect via `tokio::net::TcpStream`, upgrade with async fn validate_tls(
/// `async_native_tls::TlsConnector`, then wrap in `async_imap::Client`. host: &str,
async fn validate_tls(host: &str, port: u16, tls_verify: bool, email: &str, password: &str) -> AuthResult { port: u16,
tls_verify: bool,
email: &str,
password: &str,
) -> AuthResult {
let addr = format!("{host}:{port}"); let addr = format!("{host}:{port}");
let tcp_stream = match tokio::net::TcpStream::connect(&addr).await { let tcp_stream = match tokio::net::TcpStream::connect(&addr).await {
@@ -53,7 +48,6 @@ async fn validate_tls(host: &str, port: u16, tls_verify: bool, email: &str, pass
let mut client = async_imap::Client::new(tls_stream); let mut client = async_imap::Client::new(tls_stream);
// Read the server greeting before attempting login.
match client.read_response().await { match client.read_response().await {
Some(Ok(_)) => {} Some(Ok(_)) => {}
Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()), Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()),
@@ -63,8 +57,6 @@ async fn validate_tls(host: &str, port: u16, tls_verify: bool, email: &str, pass
attempt_login(client, email, password).await attempt_login(client, email, password).await
} }
/// Non-TLS path: connect via `tokio::net::TcpStream`, then wrap directly in
/// `async_imap::Client`.
async fn validate_plain(host: &str, port: u16, email: &str, password: &str) -> AuthResult { async fn validate_plain(host: &str, port: u16, email: &str, password: &str) -> AuthResult {
let addr = format!("{host}:{port}"); let addr = format!("{host}:{port}");
@@ -75,7 +67,6 @@ async fn validate_plain(host: &str, port: u16, email: &str, password: &str) -> A
let mut client = async_imap::Client::new(tcp_stream); let mut client = async_imap::Client::new(tcp_stream);
// Read the server greeting before attempting login.
match client.read_response().await { match client.read_response().await {
Some(Ok(_)) => {} Some(Ok(_)) => {}
Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()), Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()),
@@ -85,14 +76,12 @@ async fn validate_plain(host: &str, port: u16, email: &str, password: &str) -> A
attempt_login(client, email, password).await attempt_login(client, email, password).await
} }
/// Shared login logic: attempt LOGIN, logout on success, classify errors.
async fn attempt_login<T>(client: async_imap::Client<T>, email: &str, password: &str) -> AuthResult async fn attempt_login<T>(client: async_imap::Client<T>, email: &str, password: &str) -> AuthResult
where where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + std::fmt::Debug + Send, T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + std::fmt::Debug + Send,
{ {
match client.login(email, password).await { match client.login(email, password).await {
Ok(mut session) => { Ok(mut session) => {
// Successfully authenticated; clean up.
let _ = session.logout().await; let _ = session.logout().await;
AuthResult::Success AuthResult::Success
} }
@@ -100,11 +89,6 @@ where
} }
} }
/// Classify an IMAP error into `InvalidCredentials` or `ServerUnreachable`.
///
/// A `No` response from the server means the credentials were rejected.
/// Everything else (I/O errors, BAD responses, parse errors, etc.) is
/// treated as a server/network problem.
fn classify_login_error(err: ImapError) -> AuthResult { fn classify_login_error(err: ImapError) -> AuthResult {
match err { match err {
ImapError::No(_) => AuthResult::InvalidCredentials, ImapError::No(_) => AuthResult::InvalidCredentials,
@@ -116,14 +100,11 @@ fn classify_login_error(err: ImapError) -> AuthResult {
mod tests { mod tests {
use super::*; use super::*;
/// Connecting to an unreachable server should return `ServerUnreachable`.
///
/// We use 127.0.0.1:19999, a port that (with very high probability) has
/// nothing listening on localhost.
#[tokio::test] #[tokio::test]
async fn unreachable_server_returns_server_unreachable() { async fn unreachable_server_returns_server_unreachable() {
let result = let result =
validate_imap_credentials("127.0.0.1", 19999, false, false, "user@test.com", "pass").await; validate_imap_credentials("127.0.0.1", 19999, false, false, "user@test.com", "pass")
.await;
assert!( assert!(
matches!(result, AuthResult::ServerUnreachable(_)), matches!(result, AuthResult::ServerUnreachable(_)),
@@ -131,11 +112,11 @@ mod tests {
); );
} }
/// Same test but with TLS enabled: should also return `ServerUnreachable`.
#[tokio::test] #[tokio::test]
async fn unreachable_server_tls_returns_server_unreachable() { async fn unreachable_server_tls_returns_server_unreachable() {
let result = let result =
validate_imap_credentials("127.0.0.1", 19999, true, false, "user@test.com", "pass").await; validate_imap_credentials("127.0.0.1", 19999, true, false, "user@test.com", "pass")
.await;
assert!( assert!(
matches!(result, AuthResult::ServerUnreachable(_)), matches!(result, AuthResult::ServerUnreachable(_)),
@@ -143,14 +124,12 @@ mod tests {
); );
} }
/// Verify the error classifier maps `No` to `InvalidCredentials`.
#[test] #[test]
fn classify_no_as_invalid_credentials() { fn classify_no_as_invalid_credentials() {
let err = ImapError::No("Authentication failed".into()); let err = ImapError::No("Authentication failed".into());
assert_eq!(classify_login_error(err), AuthResult::InvalidCredentials); assert_eq!(classify_login_error(err), AuthResult::InvalidCredentials);
} }
/// Verify the error classifier maps I/O errors to `ServerUnreachable`.
#[test] #[test]
fn classify_io_as_server_unreachable() { fn classify_io_as_server_unreachable() {
let err = ImapError::Io(std::io::Error::new( let err = ImapError::Io(std::io::Error::new(
@@ -164,7 +143,6 @@ mod tests {
); );
} }
/// Verify the error classifier maps `Bad` responses to `ServerUnreachable`.
#[test] #[test]
fn classify_bad_as_server_unreachable() { fn classify_bad_as_server_unreachable() {
let err = ImapError::Bad("server error".into()); let err = ImapError::Bad("server error".into());
+8 -55
View File
@@ -37,13 +37,6 @@ pub struct AccountSession {
pub user_agent: String, pub user_agent: String,
} }
/// Thread-safe, in-memory session store backed by `DashMap`.
///
/// Shared across all Axum handlers via `Arc`. Sessions expire after
/// `timeout` of inactivity (sliding window).
///
/// Browser binding allows multiple accounts to be associated with a single
/// browser session, enabling account switching without re-authentication.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SessionStore { pub struct SessionStore {
sessions: Arc<DashMap<SessionId, AccountSession>>, sessions: Arc<DashMap<SessionId, AccountSession>>,
@@ -81,7 +74,10 @@ impl SessionStore {
if let Some(value) = self.tls_verify_override { if let Some(value) = self.tls_verify_override {
return value; return value;
} }
let normalized = host.trim().trim_matches(|ch| ch == '[' || ch == ']').to_ascii_lowercase(); let normalized = host
.trim()
.trim_matches(|ch| ch == '[' || ch == ']')
.to_ascii_lowercase();
!matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1") !matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1")
} }
@@ -220,7 +216,6 @@ impl SessionStore {
pub fn remove_account(&self, account_id: &str) -> bool { pub fn remove_account(&self, account_id: &str) -> bool {
if let Some((removed_account_id, session_id)) = self.account_to_session.remove(account_id) { if let Some((removed_account_id, session_id)) = self.account_to_session.remove(account_id) {
// Remove from sessions; restore account_to_session on panic.
if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.sessions.remove(&session_id); self.sessions.remove(&session_id);
})) { })) {
@@ -229,7 +224,6 @@ impl SessionStore {
std::panic::resume_unwind(payload); std::panic::resume_unwind(payload);
} }
// Use reverse index for O(1) browser lookup instead of iterating.
if let Some((_, browser_id)) = self.account_to_browser.remove(account_id) if let Some((_, browser_id)) = self.account_to_browser.remove(account_id)
&& let Some(mut entry) = self.browsers.get_mut(&browser_id) && let Some(mut entry) = self.browsers.get_mut(&browser_id)
&& let Some(pos) = entry.value().iter().position(|id| id == account_id) && let Some(pos) = entry.value().iter().position(|id| id == account_id)
@@ -242,8 +236,6 @@ impl SessionStore {
} }
} }
/// Store a display-only User-Agent string for active-session diagnostics.
/// It is never used for authentication or authorization decisions.
pub fn set_user_agent(&self, account_id: &str, user_agent: String) { pub fn set_user_agent(&self, account_id: &str, user_agent: String) {
let Some(session_id) = self let Some(session_id) = self
.account_to_session .account_to_session
@@ -257,8 +249,6 @@ impl SessionStore {
} }
} }
/// Return all currently live account sessions belonging to the same
/// RustBox user, including sessions created by other browsers.
pub fn get_user_sessions(&self, user_hash: &str) -> Vec<AccountSession> { pub fn get_user_sessions(&self, user_hash: &str) -> Vec<AccountSession> {
let now = Instant::now(); let now = Instant::now();
let mut result: Vec<AccountSession> = self let mut result: Vec<AccountSession> = self
@@ -280,7 +270,6 @@ impl SessionStore {
result result
} }
/// Revoke one session only when it belongs to the requested user.
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool { pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
let Some(session_id) = self let Some(session_id) = self
.account_to_session .account_to_session
@@ -299,7 +288,6 @@ impl SessionStore {
self.remove_account(account_id) self.remove_account(account_id)
} }
/// Revoke every session for a user except the current account session.
pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize { pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize {
let account_ids: Vec<String> = self let account_ids: Vec<String> = self
.sessions .sessions
@@ -321,10 +309,6 @@ impl SessionStore {
pub fn remove_browser(&self, browser_id: &str) { pub fn remove_browser(&self, browser_id: &str) {
if let Some((_, account_ids)) = self.browsers.remove(browser_id) { if let Some((_, account_ids)) = self.browsers.remove(browser_id) {
// Clean up each account. Individual failures are tolerated --
// the browser entry is already gone, so the accounts become
// unreachable via browser lookups. They will be reaped by
// purge_expired or a subsequent remove_account call.
for account_id in account_ids { for account_id in account_ids {
self.account_to_browser.remove(&account_id); self.account_to_browser.remove(&account_id);
if let Some((_, session_id)) = self.account_to_session.remove(&account_id) { if let Some((_, session_id)) = self.account_to_session.remove(&account_id) {
@@ -389,17 +373,13 @@ impl SessionStore {
self.sessions.remove(token).is_some() self.sessions.remove(token).is_some()
} }
/// Remove an account's entries from `account_to_session` and `browsers`.
/// If a browser's account list becomes empty, the browser entry is removed
/// too -- keeping it would serve no purpose and would itself be a leak.
fn evict_account(&self, account_id: &str) { fn evict_account(&self, account_id: &str) {
self.account_to_session.remove(account_id); self.account_to_session.remove(account_id);
self.account_to_browser.remove(account_id); self.account_to_browser.remove(account_id);
// We cannot predict which browser owns this account, so scan all.
self.browsers.retain(|_, accounts| { self.browsers.retain(|_, accounts| {
accounts.retain(|id| id != account_id); accounts.retain(|id| id != account_id);
// Keep the browser entry only if it still has accounts.
!accounts.is_empty() !accounts.is_empty()
}); });
} }
@@ -408,7 +388,6 @@ impl SessionStore {
pub fn purge_expired(&self) { pub fn purge_expired(&self) {
let now = Instant::now(); let now = Instant::now();
// Collect expired account IDs while retaining live sessions.
let mut expired_accounts = Vec::new(); let mut expired_accounts = Vec::new();
self.sessions.retain(|_, state| { self.sessions.retain(|_, state| {
let effective_timeout = state.timeout_override.unwrap_or(self.timeout); let effective_timeout = state.timeout_override.unwrap_or(self.timeout);
@@ -424,14 +403,10 @@ impl SessionStore {
} }
} }
/// Verify internal consistency across all four maps. Returns a list of
/// inconsistency descriptions. An empty vec means the store is consistent.
/// Intended for testing and diagnostics.
#[allow(dead_code)] #[allow(dead_code)]
pub fn check_consistency(&self) -> Vec<String> { pub fn check_consistency(&self) -> Vec<String> {
let mut issues = Vec::new(); let mut issues = Vec::new();
// Every session's account_id should have a matching account_to_session entry.
for entry in self.sessions.iter() { for entry in self.sessions.iter() {
let token = entry.key(); let token = entry.key();
let account_id = &entry.value().account_id; let account_id = &entry.value().account_id;
@@ -451,7 +426,6 @@ impl SessionStore {
} }
} }
// Every account_to_session entry should point to an existing session.
for entry in self.account_to_session.iter() { for entry in self.account_to_session.iter() {
let account_id = entry.key(); let account_id = entry.key();
let session_id = entry.value(); let session_id = entry.value();
@@ -462,7 +436,6 @@ impl SessionStore {
} }
} }
// Every account_id in a browser list should exist in account_to_session.
for entry in self.browsers.iter() { for entry in self.browsers.iter() {
let browser_id = entry.key(); let browser_id = entry.key();
for account_id in entry.value().iter() { for account_id in entry.value().iter() {
@@ -474,7 +447,6 @@ impl SessionStore {
} }
} }
// Every account_to_browser entry should reference a valid account and browser.
for entry in self.account_to_browser.iter() { for entry in self.account_to_browser.iter() {
let account_id = entry.key(); let account_id = entry.key();
let browser_id = entry.value(); let browser_id = entry.value();
@@ -878,8 +850,6 @@ mod tests {
#[test] #[test]
fn sliding_window_refreshes_expiry() { fn sliding_window_refreshes_expiry() {
// Use generous margins so the test is reliable under CI / Nix sandbox
// CPU pressure where sleeps can overshoot significantly.
let store = SessionStore::new(Duration::from_millis(800)); let store = SessionStore::new(Duration::from_millis(800));
let browser_id = store.create_browser(); let browser_id = store.create_browser();
@@ -911,8 +881,6 @@ mod tests {
assert_eq!(session.imap_port, 993); assert_eq!(session.imap_port, 993);
} }
// --- Consistency tests ---
fn add_test_account( fn add_test_account(
store: &SessionStore, store: &SessionStore,
browser_id: &str, browser_id: &str,
@@ -996,26 +964,23 @@ mod tests {
); );
assert_eq!(store.sessions.len(), 0); assert_eq!(store.sessions.len(), 0);
assert_eq!(store.account_to_session.len(), 0); assert_eq!(store.account_to_session.len(), 0);
// Browser entry is removed when its account list becomes empty.
assert!(!store.browser_exists(&browser)); assert!(!store.browser_exists(&browser));
} }
#[test] #[test]
fn purge_expired_respects_timeout_override() { fn purge_expired_respects_timeout_override() {
let store = short_lived_store(); // 50ms default let store = short_lived_store();
let browser = store.create_browser(); let browser = store.create_browser();
// Add one account with a long timeout override
let (token, account_id) = add_test_account(&store, &browser, "long@example.com"); let (token, account_id) = add_test_account(&store, &browser, "long@example.com");
store.sessions.get_mut(&token).unwrap().timeout_override = Some(Duration::from_secs(3600)); store.sessions.get_mut(&token).unwrap().timeout_override = Some(Duration::from_secs(3600));
// Add one with default (short) timeout
add_test_account(&store, &browser, "short@example.com"); add_test_account(&store, &browser, "short@example.com");
thread::sleep(Duration::from_millis(100)); thread::sleep(Duration::from_millis(100));
store.purge_expired(); store.purge_expired();
// The long-timeout session should survive.
assert!(store.get(&token).is_some()); assert!(store.get(&token).is_some());
assert!(store.account_to_session.contains_key(&account_id)); assert!(store.account_to_session.contains_key(&account_id));
assert_eq!(store.sessions.len(), 1); assert_eq!(store.sessions.len(), 1);
@@ -1051,18 +1016,15 @@ mod tests {
issues.is_empty(), issues.is_empty(),
"store should be consistent after mixed operations, got: {issues:?}" "store should be consistent after mixed operations, got: {issues:?}"
); );
// Only b@example.com should remain.
assert_eq!(store.sessions.len(), 1); assert_eq!(store.sessions.len(), 1);
assert_eq!(store.account_to_session.len(), 1); assert_eq!(store.account_to_session.len(), 1);
} }
// --- Reverse index tests ---
#[test] #[test]
fn remove_account_uses_reverse_index() { fn remove_account_uses_reverse_index() {
let store = long_lived_store(); let store = long_lived_store();
// Create many browsers to demonstrate the O(1) lookup
let mut other_browsers = Vec::new(); let mut other_browsers = Vec::new();
for _ in 0..100 { for _ in 0..100 {
let bid = store.create_browser(); let bid = store.create_browser();
@@ -1095,23 +1057,19 @@ mod tests {
true, true,
); );
// Verify reverse index was populated
assert!(store.account_to_browser.contains_key(&account_id)); assert!(store.account_to_browser.contains_key(&account_id));
assert_eq!( assert_eq!(
store.account_to_browser.get(&account_id).unwrap().value(), store.account_to_browser.get(&account_id).unwrap().value(),
&target_browser &target_browser
); );
// Remove the account -- uses O(1) reverse index, not O(n) iteration
assert!(store.remove_account(&account_id)); assert!(store.remove_account(&account_id));
// Verify full cleanup
assert!(store.get(&token).is_none()); assert!(store.get(&token).is_none());
assert!(!store.account_to_session.contains_key(&account_id)); assert!(!store.account_to_session.contains_key(&account_id));
assert!(!store.account_to_browser.contains_key(&account_id)); assert!(!store.account_to_browser.contains_key(&account_id));
assert!(store.get_browser_accounts(&target_browser).is_empty()); assert!(store.get_browser_accounts(&target_browser).is_empty());
// Other browsers are untouched
for bid in &other_browsers { for bid in &other_browsers {
assert_eq!(store.get_browser_accounts(bid).len(), 1); assert_eq!(store.get_browser_accounts(bid).len(), 1);
} }
@@ -1184,8 +1142,6 @@ mod tests {
assert!(store.get_browser_accounts(&browser_id).is_empty()); assert!(store.get_browser_accounts(&browser_id).is_empty());
} }
// --- Cascade eviction tests ---
#[test] #[test]
fn purge_expired_cascades_to_browsers_and_account_map() { fn purge_expired_cascades_to_browsers_and_account_map() {
let store = short_lived_store(); let store = short_lived_store();
@@ -1224,7 +1180,6 @@ mod tests {
let store = SessionStore::new(Duration::from_millis(150)); let store = SessionStore::new(Duration::from_millis(150));
let browser_id = store.create_browser(); let browser_id = store.create_browser();
// First account -- will expire.
let (_token1, account1) = store.add_account_to_browser( let (_token1, account1) = store.add_account_to_browser(
&browser_id, &browser_id,
"old@example.com".into(), "old@example.com".into(),
@@ -1240,7 +1195,6 @@ mod tests {
thread::sleep(Duration::from_millis(100)); thread::sleep(Duration::from_millis(100));
// Second account -- added later, still alive after sleep.
let (_token2, account2) = store.add_account_to_browser( let (_token2, account2) = store.add_account_to_browser(
&browser_id, &browser_id,
"new@example.com".into(), "new@example.com".into(),
@@ -1297,7 +1251,6 @@ mod tests {
thread::sleep(Duration::from_millis(100)); thread::sleep(Duration::from_millis(100));
// Accessing the expired session should trigger cascade cleanup.
assert!(store.get(&token).is_none()); assert!(store.get(&token).is_none());
assert!( assert!(
+2 -16
View File
@@ -8,29 +8,17 @@ mod embedded {
embed_migrations!("migrations"); embed_migrations!("migrations");
} }
/// Compute a SHA-256 hash of the given email address and return it as a
/// lowercase hex-encoded string (64 characters).
pub fn hash_email(email: &str) -> String { pub fn hash_email(email: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(email.as_bytes()); hasher.update(email.as_bytes());
let result = hasher.finalize(); let result = hasher.finalize();
// Format each byte as two lowercase hex digits.
result.iter().map(|b| format!("{b:02x}")).collect() result.iter().map(|b| format!("{b:02x}")).collect()
} }
/// Provision a per-user data directory under `data_dir`.
///
/// Creates `{data_dir}/{user_hash}/` with a `tantivy/` subdirectory,
/// opens (or creates) `db.sqlite`, and runs refinery migrations on it.
///
/// This function is idempotent: calling it multiple times with the same
/// arguments is safe and will not produce errors.
///
/// Returns the path to the user directory on success.
pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result<PathBuf, String> { pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result<PathBuf, String> {
let user_dir = PathBuf::from(data_dir).join(user_hash); let user_dir = PathBuf::from(data_dir).join(user_hash);
// Create the user directory and the tantivy subdirectory.
let tantivy_dir = user_dir.join("tantivy"); let tantivy_dir = user_dir.join("tantivy");
fs::create_dir_all(&tantivy_dir).map_err(|e| format!("failed to create directories: {e}"))?; fs::create_dir_all(&tantivy_dir).map_err(|e| format!("failed to create directories: {e}"))?;
@@ -38,17 +26,15 @@ pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result<PathBuf, S
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
if let Ok(mut perms) = fs::metadata(&user_dir).map(|m| m.permissions()) { if let Ok(mut perms) = fs::metadata(&user_dir).map(|m| m.permissions()) {
perms.set_mode(0o700); // rwx------ perms.set_mode(0o700);
let _ = fs::set_permissions(&user_dir, perms); let _ = fs::set_permissions(&user_dir, perms);
} }
} }
// Open (or create) the SQLite database.
let db_path = user_dir.join("db.sqlite"); let db_path = user_dir.join("db.sqlite");
let mut conn = rusqlite::Connection::open(&db_path) let mut conn = rusqlite::Connection::open(&db_path)
.map_err(|e| format!("failed to open database: {e}"))?; .map_err(|e| format!("failed to open database: {e}"))?;
// Run refinery migrations.
embedded::migrations::runner() embedded::migrations::runner()
.run(&mut conn) .run(&mut conn)
.map_err(|e| format!("failed to run migrations: {e}"))?; .map_err(|e| format!("failed to run migrations: {e}"))?;
-12
View File
@@ -4,7 +4,6 @@ use std::io::BufReader;
use crate::db::calendar::CreateEvent; use crate::db::calendar::CreateEvent;
/// Parse iCalendar text and return a list of events ready for database insertion.
pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> { pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> {
let reader = BufReader::new(ics_text.as_bytes()); let reader = BufReader::new(ics_text.as_bytes());
let parser = IcalParser::new(reader); let parser = IcalParser::new(reader);
@@ -72,13 +71,11 @@ pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> {
title = "(No title)".to_string(); title = "(No title)".to_string();
} }
// If end_time is empty, default to start_time + 1 hour (or same day for all-day)
if end_time.is_empty() { if end_time.is_empty() {
end_time = start_time.clone(); end_time = start_time.clone();
} }
if start_time.is_empty() { if start_time.is_empty() {
// Skip events without a start time
continue; continue;
} }
@@ -110,16 +107,11 @@ pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> {
Ok(events) Ok(events)
} }
/// Check if a DTSTART/DTEND value is date-only (no time component).
fn is_date_only(value: &str) -> bool { fn is_date_only(value: &str) -> bool {
// Date-only values are 8 characters: YYYYMMDD
// Date-time values are at least 15: YYYYMMDDTHHmmSS
value.len() == 8 && value.chars().all(|c| c.is_ascii_digit()) value.len() == 8 && value.chars().all(|c| c.is_ascii_digit())
} }
/// Parse an iCalendar date/datetime value to ISO 8601 format.
fn parse_ical_datetime(value: &str, params: &Option<Vec<(String, Vec<String>)>>) -> String { fn parse_ical_datetime(value: &str, params: &Option<Vec<(String, Vec<String>)>>) -> String {
// Check for TZID parameter
let _tzid = params.as_ref().and_then(|ps| { let _tzid = params.as_ref().and_then(|ps| {
ps.iter() ps.iter()
.find(|(k, _)| k == "TZID") .find(|(k, _)| k == "TZID")
@@ -130,12 +122,10 @@ fn parse_ical_datetime(value: &str, params: &Option<Vec<(String, Vec<String>)>>)
let value = value.trim(); let value = value.trim();
if value.len() == 8 && value.chars().all(|c| c.is_ascii_digit()) { if value.len() == 8 && value.chars().all(|c| c.is_ascii_digit()) {
// Date only: YYYYMMDD -> YYYY-MM-DD
return format!("{}-{}-{}", &value[0..4], &value[4..6], &value[6..8]); return format!("{}-{}-{}", &value[0..4], &value[4..6], &value[6..8]);
} }
if value.len() >= 15 && value.contains('T') { if value.len() >= 15 && value.contains('T') {
// DateTime: YYYYMMDDTHHmmSS or YYYYMMDDTHHmmSSZ
let date_part = &value[0..8]; let date_part = &value[0..8];
let time_part = &value[9..]; let time_part = &value[9..];
@@ -157,11 +147,9 @@ fn parse_ical_datetime(value: &str, params: &Option<Vec<(String, Vec<String>)>>)
return format!("{year}-{month}-{day}T{hour}:{min}:{sec}{suffix}"); return format!("{year}-{month}-{day}T{hour}:{min}:{sec}{suffix}");
} }
// Fallback: return as-is
value.to_string() value.to_string()
} }
/// Extract email address from a cal-address value (e.g., "mailto:user@example.com").
fn extract_email_from_cal_address(value: &str) -> String { fn extract_email_from_cal_address(value: &str) -> String {
if let Some(stripped) = value.strip_prefix("mailto:") { if let Some(stripped) = value.strip_prefix("mailto:") {
stripped.to_string() stripped.to_string()
-64
View File
@@ -7,146 +7,102 @@ use figment::{
use ipnet::IpNet; use ipnet::IpNet;
use serde::Deserialize; use serde::Deserialize;
/// Application configuration loaded via figment.
///
/// Layers (lowest to highest priority):
/// 1. Serde defaults
/// 2. Optional TOML (`RUSTBOX_CONFIG`, default `/etc/rustbox/rustbox.toml`)
/// 3. Environment variables (flat, e.g. `PORT`, `IMAP_HOST`)
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct AppConfig { pub struct AppConfig {
/// Bind address for the HTTP server.
#[serde(default = "default_host")] #[serde(default = "default_host")]
pub host: String, pub host: String,
/// Port for the HTTP server.
#[serde(default = "default_port")] #[serde(default = "default_port")]
pub port: u16, pub port: u16,
/// IMAP server hostname. Defaults to localhost when omitted.
#[serde(default = "default_imap_host")] #[serde(default = "default_imap_host")]
pub imap_host: Option<String>, pub imap_host: Option<String>,
/// IMAP server port.
#[serde(default = "default_imap_port")] #[serde(default = "default_imap_port")]
pub imap_port: u16, pub imap_port: u16,
/// SMTP server hostname. When unset or blank, IMAP host is used.
#[serde(default)] #[serde(default)]
pub smtp_host: Option<String>, pub smtp_host: Option<String>,
/// SMTP server port.
#[serde(default = "default_smtp_port")] #[serde(default = "default_smtp_port")]
pub smtp_port: u16, pub smtp_port: u16,
/// Whether TLS is enabled for mail connections.
#[serde(default = "default_tls_enabled")] #[serde(default = "default_tls_enabled")]
pub tls_enabled: bool, pub tls_enabled: bool,
/// Optional certificate/hostname verification override for IMAP and SMTP.
/// When omitted, verification is disabled only for localhost/loopback endpoints
/// and enabled for remote hosts.
#[serde(default)] #[serde(default)]
pub tls_verify: Option<bool>, pub tls_verify: Option<bool>,
/// Maximum size of one uploaded compose attachment, in MiB.
#[serde(default = "default_max_attachment_size_mb")] #[serde(default = "default_max_attachment_size_mb")]
pub max_attachment_size_mb: usize, pub max_attachment_size_mb: usize,
/// Maximum size of the complete RFC822/MIME message submitted to the MTA, in MiB.
#[serde(default = "default_max_message_size_mb")] #[serde(default = "default_max_message_size_mb")]
pub max_message_size_mb: usize, pub max_message_size_mb: usize,
/// Directory for persistent data storage.
#[serde(default = "default_data_dir")] #[serde(default = "default_data_dir")]
pub data_dir: String, pub data_dir: String,
/// Session timeout in hours.
#[serde(default = "default_session_timeout_hours")] #[serde(default = "default_session_timeout_hours")]
pub session_timeout_hours: u64, pub session_timeout_hours: u64,
/// Whether users may request a persistent "remember me" session.
#[serde(default = "default_remember_me_enabled")] #[serde(default = "default_remember_me_enabled")]
pub remember_me_enabled: bool, pub remember_me_enabled: bool,
/// Lifetime of a remembered session in days.
#[serde(default = "default_remember_me_days")] #[serde(default = "default_remember_me_days")]
pub remember_me_days: u64, pub remember_me_days: u64,
/// Environment the application is running in (development, production)
#[serde(default = "default_environment")] #[serde(default = "default_environment")]
pub environment: String, pub environment: String,
/// Server-wide light/dark theme used before authentication (including the login screen).
/// Authenticated per-user display preferences still take precedence.
#[serde(default = "default_theme")] #[serde(default = "default_theme")]
pub default_theme: String, pub default_theme: String,
/// Server-wide interface style used on the login screen and by users whose
/// interface style is set to `default`. Examples: `gmail`, `proton`, `zoho`.
#[serde(default = "default_interface_skin")] #[serde(default = "default_interface_skin")]
pub default_interface_skin: String, pub default_interface_skin: String,
/// Optional base path prefix (e.g. "/mail") for serving behind a reverse proxy subpath.
#[serde(default)] #[serde(default)]
pub base_path: Option<String>, pub base_path: Option<String>,
/// ManageSieve host. When unset, the authenticated account's IMAP host is used.
#[serde(default)] #[serde(default)]
pub sieve_host: Option<String>, pub sieve_host: Option<String>,
/// ManageSieve TCP port.
#[serde(default = "default_sieve_port")] #[serde(default = "default_sieve_port")]
pub sieve_port: u16, pub sieve_port: u16,
/// ManageSieve transport: `starttls`, `implicit` or `none`.
#[serde(default = "default_sieve_tls_mode")] #[serde(default = "default_sieve_tls_mode")]
pub sieve_tls_mode: String, pub sieve_tls_mode: String,
/// Explicit opt-in for sending reusable credentials over a cleartext Sieve connection.
#[serde(default)] #[serde(default)]
pub sieve_allow_insecure_auth: bool, pub sieve_allow_insecure_auth: bool,
/// Network timeout for ManageSieve connect/read/write operations.
#[serde(default = "default_sieve_timeout_secs")] #[serde(default = "default_sieve_timeout_secs")]
pub sieve_timeout_secs: u64, pub sieve_timeout_secs: u64,
/// Comma-separated list of trusted proxy IPs or CIDR ranges that are allowed to set X-Forwarded-For.
#[serde(default)] #[serde(default)]
pub trusted_proxies: Option<String>, pub trusted_proxies: Option<String>,
/// Administrator-defined extra headers added to every outgoing message.
/// Each entry uses `Header-Name: value`. Structural/MIME headers are rejected.
#[serde(default)] #[serde(default)]
pub outgoing_headers: Vec<String>, pub outgoing_headers: Vec<String>,
/// Enable Web Key Directory lookups for OpenPGP recipient keys.
#[serde(default = "default_pgp_wkd_enabled")] #[serde(default = "default_pgp_wkd_enabled")]
pub pgp_wkd_enabled: bool, pub pgp_wkd_enabled: bool,
/// Allow optional HKP-over-HTTPS fallback for OpenPGP discovery.
#[serde(default)] #[serde(default)]
pub pgp_hkp_enabled: bool, pub pgp_hkp_enabled: bool,
/// HKP-compatible HTTPS endpoint used when the fallback is enabled.
#[serde(default = "default_pgp_hkp_url")] #[serde(default = "default_pgp_hkp_url")]
pub pgp_hkp_url: String, pub pgp_hkp_url: String,
/// Timeout for WKD/HKP network operations.
#[serde(default = "default_pgp_network_timeout_secs")] #[serde(default = "default_pgp_network_timeout_secs")]
pub pgp_network_timeout_secs: u64, pub pgp_network_timeout_secs: u64,
/// Maximum accepted OpenPGP key response size.
#[serde(default = "default_pgp_max_key_bytes")] #[serde(default = "default_pgp_max_key_bytes")]
pub pgp_max_key_bytes: usize, pub pgp_max_key_bytes: usize,
/// Allow authenticated users to export private OpenPGP key material.
/// Disabled by default; public-key export is always available.
#[serde(default)] #[serde(default)]
pub pgp_allow_private_export: bool, pub pgp_allow_private_export: bool,
/// Administrator defaults for special IMAP folders. Per-account settings override these.
#[serde(default)] #[serde(default)]
pub default_sent_folder: Option<String>, pub default_sent_folder: Option<String>,
#[serde(default)] #[serde(default)]
@@ -302,7 +258,6 @@ impl Default for AppConfig {
} }
impl AppConfig { impl AppConfig {
/// Use the root path when no reverse-proxy prefix is configured.
pub fn normalized_base_path(&self) -> &str { pub fn normalized_base_path(&self) -> &str {
self.base_path self.base_path
.as_deref() .as_deref()
@@ -310,8 +265,6 @@ impl AppConfig {
.unwrap_or("/") .unwrap_or("/")
} }
/// Certificate verification policy for a configured mail host. Explicit
/// `tls_verify` wins. Without it, only localhost/loopback skips validation.
pub fn tls_verify_for_host(&self, host: &str) -> bool { pub fn tls_verify_for_host(&self, host: &str) -> bool {
if let Some(value) = self.tls_verify { if let Some(value) = self.tls_verify {
return value; return value;
@@ -331,8 +284,6 @@ impl AppConfig {
self.max_message_size_mb.saturating_mul(1024 * 1024) self.max_message_size_mb.saturating_mul(1024 * 1024)
} }
/// Parse the `trusted_proxies` field into IP or CIDR entries.
/// Logs warnings for unparseable entries.
pub fn parsed_trusted_proxies(&self) -> Vec<TrustedProxy> { pub fn parsed_trusted_proxies(&self) -> Vec<TrustedProxy> {
let Some(ref proxies) = self.trusted_proxies else { let Some(ref proxies) = self.trusted_proxies else {
return vec![]; return vec![];
@@ -409,10 +360,6 @@ impl AppConfig {
Ok(parsed) Ok(parsed)
} }
/// Load configuration by layering serde defaults, optional TOML, and environment variables.
///
/// Environment variables are read without a prefix and mapped directly to
/// struct fields via case-insensitive matching (e.g. `IMAP_HOST` → `imap_host`).
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub fn load() -> Result<Self, figment::Error> { pub fn load() -> Result<Self, figment::Error> {
let path = std::env::var("RUSTBOX_CONFIG") let path = std::env::var("RUSTBOX_CONFIG")
@@ -423,8 +370,6 @@ impl AppConfig {
figment = figment.merge(Toml::file(path)); figment = figment.merge(Toml::file(path));
} }
// Keep Oxi-compatible flat environment names (IMAP_HOST, SMTP_HOST, ...).
// Environment variables intentionally override the TOML file.
figment.merge(Env::raw()).extract() figment.merge(Env::raw()).extract()
} }
@@ -515,8 +460,6 @@ mod tests {
#[test] #[test]
fn default_config_values_load_correctly() { fn default_config_values_load_correctly() {
// With no env vars set, all defaults should apply.
// We use figment with no providers (only serde defaults).
let config: AppConfig = Figment::new().extract().expect("defaults should load"); let config: AppConfig = Figment::new().extract().expect("defaults should load");
assert_eq!(config.host, "0.0.0.0"); assert_eq!(config.host, "0.0.0.0");
@@ -555,8 +498,6 @@ mod tests {
#[test] #[test]
fn env_var_overrides_work() { fn env_var_overrides_work() {
// Simulate env overrides via figment's tuple provider
// to verify all fields are overridable.
let config: AppConfig = Figment::new() let config: AppConfig = Figment::new()
.merge(("host", "127.0.0.1")) .merge(("host", "127.0.0.1"))
.merge(("port", 8080u16)) .merge(("port", 8080u16))
@@ -611,10 +552,6 @@ mod tests {
#[test] #[test]
fn real_env_vars_override_multi_word_fields() { fn real_env_vars_override_multi_word_fields() {
// Verify that actual environment variables with underscores
// (e.g. IMAP_HOST) correctly map to struct fields (imap_host).
// SAFETY: This test runs single-threaded (--test-threads=1)
// and cleans up env vars after use.
unsafe { unsafe {
std::env::set_var("IMAP_HOST", "test-imap.example.com"); std::env::set_var("IMAP_HOST", "test-imap.example.com");
std::env::set_var("DATA_DIR", "/tmp/oxi-test"); std::env::set_var("DATA_DIR", "/tmp/oxi-test");
@@ -625,7 +562,6 @@ mod tests {
assert_eq!(config.imap_host.as_deref(), Some("test-imap.example.com")); assert_eq!(config.imap_host.as_deref(), Some("test-imap.example.com"));
assert_eq!(config.data_dir, "/tmp/oxi-test"); assert_eq!(config.data_dir, "/tmp/oxi-test");
// Clean up
unsafe { unsafe {
std::env::remove_var("IMAP_HOST"); std::env::remove_var("IMAP_HOST");
std::env::remove_var("DATA_DIR"); std::env::remove_var("DATA_DIR");
-46
View File
@@ -2,11 +2,6 @@ use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
// ---------------------------------------------------------------------------
// Structs
// ---------------------------------------------------------------------------
/// A calendar event record, mirroring the `calendar_events` table.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct CalendarEvent { pub struct CalendarEvent {
pub id: String, pub id: String,
@@ -31,7 +26,6 @@ pub struct CalendarEvent {
pub updated_at: String, pub updated_at: String,
} }
/// Fields for creating a new calendar event.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateEvent { pub struct CreateEvent {
pub title: String, pub title: String,
@@ -72,7 +66,6 @@ fn default_source() -> String {
"manual".to_string() "manual".to_string()
} }
/// Fields for updating an existing calendar event. All optional.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateEvent { pub struct UpdateEvent {
pub title: Option<String>, pub title: Option<String>,
@@ -90,7 +83,6 @@ pub struct UpdateEvent {
pub reminder_minutes: Option<i64>, pub reminder_minutes: Option<i64>,
} }
/// Calendar settings record, mirroring the `calendar_settings` table.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct CalendarSettings { pub struct CalendarSettings {
pub default_view: String, pub default_view: String,
@@ -107,7 +99,6 @@ pub struct CalendarSettings {
pub updated_at: String, pub updated_at: String,
} }
/// Fields for updating calendar settings. All optional.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateCalendarSettings { pub struct UpdateCalendarSettings {
pub default_view: Option<String>, pub default_view: Option<String>,
@@ -124,7 +115,6 @@ pub struct UpdateCalendarSettings {
pub caldav_enabled: Option<bool>, pub caldav_enabled: Option<bool>,
} }
/// A meeting template record.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingTemplate { pub struct MeetingTemplate {
pub id: i64, pub id: i64,
@@ -135,10 +125,6 @@ pub struct MeetingTemplate {
pub created_at: String, pub created_at: String,
} }
// ---------------------------------------------------------------------------
// Row mappers
// ---------------------------------------------------------------------------
const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \ const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \ all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \ source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
@@ -200,11 +186,6 @@ fn row_to_template(row: &rusqlite::Row<'_>) -> rusqlite::Result<MeetingTemplate>
}) })
} }
// ---------------------------------------------------------------------------
// Public API — Events
// ---------------------------------------------------------------------------
/// List events in the given time range (inclusive start, inclusive end).
pub fn list_events( pub fn list_events(
conn: &Connection, conn: &Connection,
start: &str, start: &str,
@@ -229,7 +210,6 @@ pub fn list_events(
Ok(events) Ok(events)
} }
/// List all calendar events ordered by start time.
pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String> { pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String> {
let sql = format!( let sql = format!(
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC" "SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC"
@@ -248,7 +228,6 @@ pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String>
Ok(events) Ok(events)
} }
/// Get a single event by ID.
pub fn get_event(conn: &Connection, id: &str) -> Result<Option<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 = format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1");
let result = conn.query_row(&sql, params![id], row_to_event); let result = conn.query_row(&sql, params![id], row_to_event);
@@ -260,7 +239,6 @@ pub fn get_event(conn: &Connection, id: &str) -> Result<Option<CalendarEvent>, S
} }
} }
/// Create a new calendar event. Generates a UUID for the ID.
pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEvent, String> { pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEvent, String> {
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let now: String = conn let now: String = conn
@@ -302,13 +280,11 @@ pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEve
get_event(conn, &id)?.ok_or_else(|| "Event not found after insert".to_string()) get_event(conn, &id)?.ok_or_else(|| "Event not found after insert".to_string())
} }
/// Update an existing calendar event. Returns the updated event, or None if not found.
pub fn update_event( pub fn update_event(
conn: &Connection, conn: &Connection,
id: &str, id: &str,
data: &UpdateEvent, data: &UpdateEvent,
) -> Result<Option<CalendarEvent>, String> { ) -> Result<Option<CalendarEvent>, String> {
// Build SET clauses dynamically for non-None fields.
let mut sets = Vec::new(); let mut sets = Vec::new();
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
let mut idx = 1u32; let mut idx = 1u32;
@@ -371,7 +347,6 @@ pub fn update_event(
get_event(conn, id) get_event(conn, id)
} }
/// Delete an event by ID. Returns true if a row was deleted.
pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> { pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn let deleted = conn
.execute("DELETE FROM calendar_events WHERE id = ?1", params![id]) .execute("DELETE FROM calendar_events WHERE id = ?1", params![id])
@@ -379,7 +354,6 @@ pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> {
Ok(deleted > 0) Ok(deleted > 0)
} }
/// Find an event by its source_uid (for deduplication during ICS import).
pub fn find_event_by_source_uid( pub fn find_event_by_source_uid(
conn: &Connection, conn: &Connection,
source_uid: &str, source_uid: &str,
@@ -394,17 +368,11 @@ pub fn find_event_by_source_uid(
} }
} }
// ---------------------------------------------------------------------------
// Public API — Settings
// ---------------------------------------------------------------------------
const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \ 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, \ working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
caldav_username, caldav_enabled, updated_at"; caldav_username, caldav_enabled, updated_at";
/// Get calendar settings, inserting a default row if none exists.
pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, String> { pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, String> {
// Ensure the singleton row exists.
conn.execute( conn.execute(
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)", "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
[], [],
@@ -416,12 +384,10 @@ pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, Stri
.map_err(|e| format!("Failed to get calendar settings: {e}")) .map_err(|e| format!("Failed to get calendar settings: {e}"))
} }
/// Update calendar settings. Returns the updated settings.
pub fn update_calendar_settings( pub fn update_calendar_settings(
conn: &Connection, conn: &Connection,
data: &UpdateCalendarSettings, data: &UpdateCalendarSettings,
) -> Result<CalendarSettings, String> { ) -> Result<CalendarSettings, String> {
// Ensure the singleton row exists.
conn.execute( conn.execute(
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)", "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
[], [],
@@ -483,11 +449,6 @@ pub fn update_calendar_settings(
get_calendar_settings(conn) get_calendar_settings(conn)
} }
// ---------------------------------------------------------------------------
// Public API — Meeting Templates
// ---------------------------------------------------------------------------
/// List all meeting templates.
pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>, String> { pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
@@ -506,7 +467,6 @@ pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>,
Ok(templates) Ok(templates)
} }
/// Create a new meeting template.
pub fn create_meeting_template( pub fn create_meeting_template(
conn: &Connection, conn: &Connection,
name: &str, name: &str,
@@ -526,7 +486,6 @@ pub fn create_meeting_template(
.map_err(|e| format!("Failed to read created template: {e}")) .map_err(|e| format!("Failed to read created template: {e}"))
} }
/// Delete a meeting template by ID. Returns true if a row was deleted.
pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, String> { pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, String> {
let deleted = conn let deleted = conn
.execute("DELETE FROM meeting_templates WHERE id = ?1", params![id]) .execute("DELETE FROM meeting_templates WHERE id = ?1", params![id])
@@ -534,10 +493,6 @@ pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, Strin
Ok(deleted > 0) Ok(deleted > 0)
} }
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -773,7 +728,6 @@ mod tests {
assert_eq!(all.len(), 0); assert_eq!(all.len(), 0);
} }
// Helper to create CreateEvent variants without full Clone impl
impl CreateEvent { impl CreateEvent {
fn clone_fields(&self) -> Self { fn clone_fields(&self) -> Self {
CreateEvent { CreateEvent {
+1 -14
View File
@@ -3,7 +3,6 @@ use serde::Serialize;
use crate::db::contacts::Contact; use crate::db::contacts::Contact;
/// A contact group record, mirroring the `contact_groups` table.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ContactGroup { pub struct ContactGroup {
pub id: String, pub id: String,
@@ -13,11 +12,6 @@ pub struct ContactGroup {
pub updated_at: String, pub updated_at: String,
} }
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Create a new contact group.
pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), String> { pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), String> {
conn.execute( conn.execute(
"INSERT INTO contact_groups (id, name) VALUES (?1, ?2)", "INSERT INTO contact_groups (id, name) VALUES (?1, ?2)",
@@ -27,7 +21,6 @@ pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), Strin
Ok(()) Ok(())
} }
/// List all contact groups with their member counts.
pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> { pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
@@ -57,7 +50,6 @@ pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
Ok(groups) Ok(groups)
} }
/// Update a group's name.
pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, String> { pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, String> {
let updated = conn let updated = conn
.execute( .execute(
@@ -68,7 +60,6 @@ pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, Str
Ok(updated > 0) Ok(updated > 0)
} }
/// Delete a contact group. CASCADE handles member rows.
pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> { pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn let deleted = conn
.execute("DELETE FROM contact_groups WHERE id = ?1", params![id]) .execute("DELETE FROM contact_groups WHERE id = ?1", params![id])
@@ -76,7 +67,6 @@ pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
Ok(deleted > 0) Ok(deleted > 0)
} }
/// Add a contact to a group (INSERT OR IGNORE to avoid duplicates).
pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<(), String> { pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<(), String> {
conn.execute( conn.execute(
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?1, ?2)", "INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?1, ?2)",
@@ -86,7 +76,6 @@ pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result
Ok(()) Ok(())
} }
/// Remove a contact from a group.
pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<bool, String> { pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<bool, String> {
let deleted = conn let deleted = conn
.execute( .execute(
@@ -97,7 +86,6 @@ pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Res
Ok(deleted > 0) Ok(deleted > 0)
} }
/// List members (full Contact objects) for a group.
pub fn list_group_members(conn: &Connection, group_id: &str) -> Result<Vec<Contact>, String> { pub fn list_group_members(conn: &Connection, group_id: &str) -> Result<Vec<Contact>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
@@ -147,7 +135,6 @@ pub fn list_group_members(conn: &Connection, group_id: &str) -> Result<Vec<Conta
Ok(contacts) Ok(contacts)
} }
/// List all groups a given contact belongs to.
pub fn list_contact_groups( pub fn list_contact_groups(
conn: &Connection, conn: &Connection,
contact_id: &str, contact_id: &str,
@@ -235,7 +222,7 @@ mod tests {
add_member(&conn, "g1", "c1").unwrap(); add_member(&conn, "g1", "c1").unwrap();
add_member(&conn, "g1", "c2").unwrap(); add_member(&conn, "g1", "c2").unwrap();
// Duplicate add should be ignored
add_member(&conn, "g1", "c1").unwrap(); add_member(&conn, "g1", "c1").unwrap();
let members = list_group_members(&conn, "g1").unwrap(); let members = list_group_members(&conn, "g1").unwrap();
+25 -66
View File
@@ -1,15 +1,13 @@
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;
/// A known address extracted from message headers (not necessarily a contact).
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct KnownAddress { pub struct KnownAddress {
pub email: String, pub email: String,
pub name: String, pub name: String,
} }
/// A contact record, mirroring the `contacts` table.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Contact { pub struct Contact {
pub id: String, pub id: String,
@@ -34,7 +32,6 @@ pub struct Contact {
pub has_photo: bool, pub has_photo: bool,
} }
/// Map a rusqlite row to a Contact struct.
fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> { fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
Ok(Contact { Ok(Contact {
id: row.get(0)?, id: row.get(0)?,
@@ -49,7 +46,8 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
birthday: row.get(9)?, birthday: row.get(9)?,
emails: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(16)?).unwrap_or_default(), emails: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(16)?).unwrap_or_default(),
phones: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(17)?).unwrap_or_default(), phones: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(17)?).unwrap_or_default(),
addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?).unwrap_or_default(), addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?)
.unwrap_or_default(),
is_favorite: row.get::<_, i32>(10)? != 0, is_favorite: row.get::<_, i32>(10)? != 0,
last_contacted: row.get(11)?, last_contacted: row.get(11)?,
contact_count: row.get(12)?, contact_count: row.get(12)?,
@@ -60,22 +58,14 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
}) })
} }
/// The SELECT column list used by all queries that return `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";
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";
/// Escape `%` and `_` in a search term to prevent LIKE wildcard injection.
fn escape_like(term: &str) -> String { fn escape_like(term: &str) -> String {
term.replace('\\', "\\\\") term.replace('\\', "\\\\")
.replace('%', "\\%") .replace('%', "\\%")
.replace('_', "\\_") .replace('_', "\\_")
} }
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Insert or replace a contact. All fields are written.
pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> { pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> {
conn.execute( conn.execute(
"INSERT INTO contacts "INSERT INTO contacts
@@ -114,7 +104,6 @@ pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String
Ok(()) Ok(())
} }
/// Get a contact by its primary key `id`. Returns `None` if not found.
pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, 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 = format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1");
let result = conn.query_row(&sql, params![id], row_to_contact); let result = conn.query_row(&sql, params![id], row_to_contact);
@@ -126,7 +115,6 @@ pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, Strin
} }
} }
/// Get a contact by email address. Returns `None` if not found.
pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Contact>, String> { pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Contact>, String> {
let sql = format!( let sql = format!(
"SELECT {CONTACT_SELECT_COLS} FROM contacts \ "SELECT {CONTACT_SELECT_COLS} FROM contacts \
@@ -142,8 +130,6 @@ pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Con
} }
} }
/// List contacts with optional search filter on name/email.
/// Results are ordered by name ascending, with LIMIT/OFFSET pagination.
pub fn list_contacts( pub fn list_contacts(
conn: &Connection, conn: &Connection,
search: Option<&str>, search: Option<&str>,
@@ -160,8 +146,7 @@ pub fn list_contacts(
ORDER BY name ASC ORDER BY name ASC
LIMIT ?2 OFFSET ?3" LIMIT ?2 OFFSET ?3"
); );
// We need to pass the pattern, so we store it and handle below.
// To keep things simple we branch on has_search.
drop(pattern); drop(pattern);
(sql, Some(escaped)) (sql, Some(escaped))
} }
@@ -206,7 +191,6 @@ pub fn list_contacts(
} }
} }
/// Delete a contact by id. Returns `true` if a row was deleted.
pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> { pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn let deleted = conn
.execute("DELETE FROM contacts WHERE id = ?1", params![id]) .execute("DELETE FROM contacts WHERE id = ?1", params![id])
@@ -214,8 +198,6 @@ pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
Ok(deleted > 0) Ok(deleted > 0)
} }
/// Search contacts by name or email using LIKE with escaped wildcards.
/// Results are ordered by name ascending.
pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec<Contact>, String> { pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec<Contact>, String> {
let escaped = escape_like(query); let escaped = escape_like(query);
let pattern = format!("%{escaped}%"); let pattern = format!("%{escaped}%");
@@ -241,9 +223,6 @@ pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec
Ok(contacts) Ok(contacts)
} }
/// Search known addresses from the denormalized `known_addresses` table.
/// Excludes any addresses already present in the contacts table.
/// Returns distinct addresses ordered by email ascending.
pub fn search_known_addresses( pub fn search_known_addresses(
conn: &Connection, conn: &Connection,
query: &str, query: &str,
@@ -285,8 +264,6 @@ pub fn search_known_addresses(
Ok(addresses) Ok(addresses)
} }
/// Populate the `known_addresses` table from a single message's header fields.
/// Uses INSERT OR IGNORE so existing rows are not overwritten.
pub fn populate_known_addresses( pub fn populate_known_addresses(
conn: &Connection, conn: &Connection,
from_address: &str, from_address: &str,
@@ -294,7 +271,6 @@ pub fn populate_known_addresses(
to_json: &str, to_json: &str,
cc_json: &str, cc_json: &str,
) -> Result<(), String> { ) -> Result<(), String> {
// Insert from address.
if !from_address.is_empty() { if !from_address.is_empty() {
conn.execute( conn.execute(
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2) "INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
@@ -304,28 +280,21 @@ pub fn populate_known_addresses(
.map_err(|e| format!("Failed to insert known address (from): {e}"))?; .map_err(|e| format!("Failed to insert known address (from): {e}"))?;
} }
// Insert to addresses from JSON array.
insert_addresses_from_json(conn, to_json)?; insert_addresses_from_json(conn, to_json)?;
// Insert cc addresses from JSON array.
insert_addresses_from_json(conn, cc_json)?; insert_addresses_from_json(conn, cc_json)?;
Ok(()) Ok(())
} }
/// Parse a JSON array of `{"address": "...", "name": "..."}` objects and insert
/// each into `known_addresses`.
fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), String> { fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), String> {
let entries: Vec<serde_json::Value> = match serde_json::from_str(json) { let entries: Vec<serde_json::Value> = match serde_json::from_str(json) {
Ok(v) => v, Ok(v) => v,
Err(_) => return Ok(()), // Invalid JSON, skip silently. Err(_) => return Ok(()),
}; };
for entry in &entries { for entry in &entries {
let email = entry let email = entry.get("address").and_then(|v| v.as_str()).unwrap_or("");
.get("address")
.and_then(|v| v.as_str())
.unwrap_or("");
if email.is_empty() { if email.is_empty() {
continue; continue;
} }
@@ -341,8 +310,6 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin
Ok(()) Ok(())
} }
/// Increment the contact_count by 1 and set last_contacted to now for the
/// contact with the given email address.
#[allow(dead_code)] #[allow(dead_code)]
pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> { pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> {
conn.execute( conn.execute(
@@ -357,9 +324,6 @@ pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), Str
Ok(()) Ok(())
} }
/// Auto-add a contact if one with the same email does not already exist.
/// Uses INSERT OR IGNORE so existing contacts are not overwritten.
/// Source is set to 'auto'.
#[allow(dead_code)] #[allow(dead_code)]
pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> { pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> {
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
@@ -372,7 +336,12 @@ pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<()
Ok(()) Ok(())
} }
pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str, data: &[u8]) -> Result<(), String> { pub fn set_contact_photo(
conn: &Connection,
contact_id: &str,
content_type: &str,
data: &[u8],
) -> Result<(), String> {
conn.execute( 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')", "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')",
params![contact_id, content_type, data], params![contact_id, content_type, data],
@@ -380,7 +349,10 @@ pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str
Ok(()) Ok(())
} }
pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result<Option<(String, Vec<u8>)>, String> { pub fn get_contact_photo(
conn: &Connection,
contact_id: &str,
) -> Result<Option<(String, Vec<u8>)>, String> {
match conn.query_row( match conn.query_row(
"SELECT content_type, data FROM contact_photos WHERE contact_id = ?1", "SELECT content_type, data FROM contact_photos WHERE contact_id = ?1",
params![contact_id], params![contact_id],
@@ -393,9 +365,12 @@ pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result<Option<(
} }
pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result<bool, String> { pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result<bool, String> {
conn.execute("DELETE FROM contact_photos WHERE contact_id = ?1", params![contact_id]) conn.execute(
.map(|n| n > 0) "DELETE FROM contact_photos WHERE contact_id = ?1",
.map_err(|e| format!("Failed to delete contact photo: {e}")) params![contact_id],
)
.map(|n| n > 0)
.map_err(|e| format!("Failed to delete contact photo: {e}"))
} }
#[cfg(test)] #[cfg(test)]
@@ -403,7 +378,6 @@ mod tests {
use super::*; use super::*;
use crate::db::pool::open_test_db; use crate::db::pool::open_test_db;
/// Helper: create a sample Contact with sensible defaults.
fn sample_contact(id: &str, email: &str, name: &str) -> Contact { fn sample_contact(id: &str, email: &str, name: &str) -> Contact {
Contact { Contact {
id: id.to_string(), id: id.to_string(),
@@ -457,7 +431,6 @@ mod tests {
assert_eq!(fetched.id, "c2"); assert_eq!(fetched.id, "c2");
assert_eq!(fetched.name, "Bob"); assert_eq!(fetched.name, "Bob");
// Non-existent email returns None.
let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap(); let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap();
assert!(missing.is_none()); assert!(missing.is_none());
} }
@@ -471,7 +444,6 @@ mod tests {
let all = list_contacts(&conn, None, 100, 0).unwrap(); let all = list_contacts(&conn, None, 100, 0).unwrap();
assert_eq!(all.len(), 2); assert_eq!(all.len(), 2);
// Ordered by name ASC: Alice, Bob.
assert_eq!(all[0].name, "Alice"); assert_eq!(all[0].name, "Alice");
assert_eq!(all[1].name, "Bob"); assert_eq!(all[1].name, "Bob");
} }
@@ -486,12 +458,10 @@ mod tests {
.unwrap(); .unwrap();
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob Jones")).unwrap(); upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob Jones")).unwrap();
// Search by name should find only Alice.
let results = search_contacts(&conn, "Alice", 10).unwrap(); let results = search_contacts(&conn, "Alice", 10).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Alice Smith"); assert_eq!(results[0].name, "Alice Smith");
// Search by email should find only Bob.
let results = search_contacts(&conn, "bob@", 10).unwrap(); let results = search_contacts(&conn, "bob@", 10).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Bob Jones"); assert_eq!(results[0].name, "Bob Jones");
@@ -505,11 +475,9 @@ mod tests {
let deleted = delete_contact(&conn, "c1").unwrap(); let deleted = delete_contact(&conn, "c1").unwrap();
assert!(deleted); assert!(deleted);
// Should be gone now.
let fetched = get_contact(&conn, "c1").unwrap(); let fetched = get_contact(&conn, "c1").unwrap();
assert!(fetched.is_none()); assert!(fetched.is_none());
// Deleting again returns false.
let deleted_again = delete_contact(&conn, "c1").unwrap(); let deleted_again = delete_contact(&conn, "c1").unwrap();
assert!(!deleted_again); assert!(!deleted_again);
} }
@@ -526,12 +494,11 @@ mod tests {
assert_eq!(fetched.source, "auto"); assert_eq!(fetched.source, "auto");
assert_eq!(fetched.name, "Auto User"); assert_eq!(fetched.name, "Auto User");
// Auto-adding the same email again should be a no-op (INSERT OR IGNORE).
auto_add_contact(&conn, "auto@example.com", "Different Name").unwrap(); auto_add_contact(&conn, "auto@example.com", "Different Name").unwrap();
let fetched2 = get_contact_by_email(&conn, "auto@example.com") let fetched2 = get_contact_by_email(&conn, "auto@example.com")
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(fetched2.name, "Auto User"); // Name should NOT change. assert_eq!(fetched2.name, "Auto User");
} }
#[test] #[test]
@@ -539,7 +506,6 @@ mod tests {
let conn = open_test_db(); let conn = open_test_db();
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap(); upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
// Increment twice.
increment_contact_count(&conn, "alice@example.com").unwrap(); increment_contact_count(&conn, "alice@example.com").unwrap();
increment_contact_count(&conn, "alice@example.com").unwrap(); increment_contact_count(&conn, "alice@example.com").unwrap();
@@ -554,7 +520,6 @@ mod tests {
fn test_search_known_addresses_basic() { fn test_search_known_addresses_basic() {
let conn = open_test_db(); let conn = open_test_db();
// Populate known_addresses directly (simulating what happens at sync time).
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap(); populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
populate_known_addresses( populate_known_addresses(
&conn, &conn,
@@ -588,14 +553,12 @@ mod tests {
fn test_search_known_addresses_excludes_contacts() { fn test_search_known_addresses_excludes_contacts() {
let conn = open_test_db(); let conn = open_test_db();
// Add alice@test.com as a contact.
upsert_contact( upsert_contact(
&conn, &conn,
&sample_contact("c1", "alice@test.com", "Alice Contact"), &sample_contact("c1", "alice@test.com", "Alice Contact"),
) )
.unwrap(); .unwrap();
// Populate known_addresses (simulating sync).
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap(); populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
populate_known_addresses( populate_known_addresses(
&conn, &conn,
@@ -614,10 +577,8 @@ mod tests {
) )
.unwrap(); .unwrap();
// Search for "test.com" should exclude alice@test.com (she's a contact).
let results = search_known_addresses(&conn, "test.com", 10).unwrap(); let results = search_known_addresses(&conn, "test.com", 10).unwrap();
// Should only have bob and charlie (alice excluded from all sources).
assert_eq!(results.len(), 2); assert_eq!(results.len(), 2);
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect(); let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
assert!(emails.contains(&"bob@test.com")); assert!(emails.contains(&"bob@test.com"));
@@ -629,7 +590,6 @@ mod tests {
fn test_search_known_addresses_handles_invalid_json() { fn test_search_known_addresses_handles_invalid_json() {
let conn = open_test_db(); let conn = open_test_db();
// Populate known_addresses (simulating sync).
populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap(); populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap();
populate_known_addresses( populate_known_addresses(
&conn, &conn,
@@ -663,7 +623,7 @@ mod tests {
let conn = open_test_db(); let conn = open_test_db();
populate_known_addresses(&conn, "alice@test.com", "Alice", "[]", "[]").unwrap(); populate_known_addresses(&conn, "alice@test.com", "Alice", "[]", "[]").unwrap();
// Same email again with non-empty name -- should update the name.
populate_known_addresses(&conn, "alice@test.com", "Alice Different", "[]", "[]").unwrap(); populate_known_addresses(&conn, "alice@test.com", "Alice Different", "[]", "[]").unwrap();
let results = search_known_addresses(&conn, "alice@test.com", 10).unwrap(); let results = search_known_addresses(&conn, "alice@test.com", 10).unwrap();
@@ -675,7 +635,6 @@ mod tests {
fn test_populate_known_addresses_skips_invalid_json() { fn test_populate_known_addresses_skips_invalid_json() {
let conn = open_test_db(); let conn = open_test_db();
// Invalid JSON in to_addresses should not cause an error.
populate_known_addresses(&conn, "sender@test.com", "Sender", "not valid json", "[]") populate_known_addresses(&conn, "sender@test.com", "Sender", "not valid json", "[]")
.unwrap(); .unwrap();
+110 -26
View File
@@ -1,7 +1,6 @@
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use serde::Serialize; use serde::Serialize;
/// A locally-saved draft message.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Draft { pub struct Draft {
pub id: String, pub id: String,
@@ -27,7 +26,6 @@ pub struct Draft {
pub updated_at: String, pub updated_at: String,
} }
/// An attachment associated with a draft.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct DraftAttachment { pub struct DraftAttachment {
pub id: String, pub id: String,
@@ -39,8 +37,6 @@ pub struct DraftAttachment {
pub created_at: String, pub created_at: String,
} }
/// Validate draft IDs before they are used as database keys or path segments.
pub fn validate_draft_id(id: &str) -> Result<(), String> { pub fn validate_draft_id(id: &str) -> Result<(), String> {
if id.is_empty() || id.len() > 128 { if id.is_empty() || id.len() > 128 {
return Err("Draft ID must be between 1 and 128 characters".to_string()); return Err("Draft ID must be between 1 and 128 characters".to_string());
@@ -54,7 +50,6 @@ pub fn validate_draft_id(id: &str) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Insert or update a draft. Uses INSERT OR REPLACE to upsert.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn upsert_draft( pub fn upsert_draft(
conn: &Connection, conn: &Connection,
@@ -119,7 +114,6 @@ pub fn upsert_draft(
Ok(()) Ok(())
} }
/// Retrieve a single draft by ID.
pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> { pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
let result = conn.query_row( 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 "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
@@ -159,7 +153,6 @@ pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
} }
} }
/// List all drafts, ordered by updated_at descending.
pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> { pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
@@ -203,8 +196,6 @@ pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
Ok(drafts) Ok(drafts)
} }
/// Update the IMAP location of the latest remote snapshot for a local draft.
pub fn set_remote_location( pub fn set_remote_location(
conn: &Connection, conn: &Connection,
id: &str, id: &str,
@@ -224,7 +215,6 @@ pub fn set_remote_location(
Ok(()) Ok(())
} }
/// Delete a draft by ID. Cascade will handle attachments in DB.
pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> { pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn let deleted = conn
.execute("DELETE FROM drafts WHERE id = ?1", params![id]) .execute("DELETE FROM drafts WHERE id = ?1", params![id])
@@ -232,7 +222,6 @@ pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
Ok(deleted > 0) Ok(deleted > 0)
} }
/// Add an attachment record for a draft.
pub fn add_draft_attachment( pub fn add_draft_attachment(
conn: &Connection, conn: &Connection,
id: &str, id: &str,
@@ -251,7 +240,6 @@ pub fn add_draft_attachment(
Ok(()) Ok(())
} }
/// Get all attachments for a draft.
pub fn get_draft_attachments( pub fn get_draft_attachments(
conn: &Connection, conn: &Connection,
draft_id: &str, draft_id: &str,
@@ -284,7 +272,6 @@ pub fn get_draft_attachments(
Ok(attachments) Ok(attachments)
} }
/// Delete a single attachment by ID.
pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result<bool, String> { pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn let deleted = conn
.execute("DELETE FROM draft_attachments WHERE id = ?1", params![id]) .execute("DELETE FROM draft_attachments WHERE id = ?1", params![id])
@@ -336,8 +323,46 @@ mod tests {
fn test_upsert_updates_existing() { fn test_upsert_updates_existing() {
let conn = open_test_db(); let conn = open_test_db();
upsert_draft(&conn, "draft-1", "bob@example.com", "", "", "v1", "body1", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); upsert_draft(
upsert_draft(&conn, "draft-1", "carol@example.com", "", "", "v2", "body2", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); &conn,
"draft-1",
"bob@example.com",
"",
"",
"v1",
"body1",
None,
None,
None,
false,
"normal",
"html",
"none",
false,
false,
false,
)
.unwrap();
upsert_draft(
&conn,
"draft-1",
"carol@example.com",
"",
"",
"v2",
"body2",
None,
None,
None,
false,
"normal",
"html",
"none",
false,
false,
false,
)
.unwrap();
let draft = get_draft(&conn, "draft-1").unwrap().unwrap(); let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
assert_eq!(draft.to_addresses, "carol@example.com"); assert_eq!(draft.to_addresses, "carol@example.com");
@@ -349,8 +374,16 @@ mod tests {
fn test_list_drafts_returns_all() { fn test_list_drafts_returns_all() {
let conn = open_test_db(); let conn = open_test_db();
upsert_draft(&conn, "draft-a", "", "", "", "First", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); upsert_draft(
upsert_draft(&conn, "draft-b", "", "", "", "Second", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); &conn, "draft-a", "", "", "", "First", "", None, None, None, false, "normal", "html",
"none", false, false, false,
)
.unwrap();
upsert_draft(
&conn, "draft-b", "", "", "", "Second", "", None, None, None, false, "normal", "html",
"none", false, false, false,
)
.unwrap();
let drafts = list_drafts(&conn).unwrap(); let drafts = list_drafts(&conn).unwrap();
assert_eq!(drafts.len(), 2); assert_eq!(drafts.len(), 2);
@@ -363,7 +396,11 @@ mod tests {
fn test_delete_draft() { fn test_delete_draft() {
let conn = open_test_db(); let conn = open_test_db();
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); upsert_draft(
&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html",
"none", false, false, false,
)
.unwrap();
assert!(get_draft(&conn, "draft-1").unwrap().is_some()); assert!(get_draft(&conn, "draft-1").unwrap().is_some());
let deleted = delete_draft(&conn, "draft-1").unwrap(); let deleted = delete_draft(&conn, "draft-1").unwrap();
@@ -374,9 +411,17 @@ mod tests {
#[test] #[test]
fn remote_location_survives_local_draft_update() { fn remote_location_survives_local_draft_update() {
let conn = open_test_db(); let conn = open_test_db();
upsert_draft(&conn, "draft-1", "", "", "", "v1", "body", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); upsert_draft(
&conn, "draft-1", "", "", "", "v1", "body", None, None, None, false, "normal", "html",
"none", false, false, false,
)
.unwrap();
set_remote_location(&conn, "draft-1", "Drafts", 42, 7).unwrap(); set_remote_location(&conn, "draft-1", "Drafts", 42, 7).unwrap();
upsert_draft(&conn, "draft-1", "", "", "", "v2", "body2", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); upsert_draft(
&conn, "draft-1", "", "", "", "v2", "body2", None, None, None, false, "normal", "html",
"none", false, false, false,
)
.unwrap();
let draft = get_draft(&conn, "draft-1").unwrap().unwrap(); let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
assert_eq!(draft.remote_folder.as_deref(), Some("Drafts")); assert_eq!(draft.remote_folder.as_deref(), Some("Drafts"));
assert_eq!(draft.remote_uid, Some(42)); assert_eq!(draft.remote_uid, Some(42));
@@ -408,7 +453,11 @@ mod tests {
fn test_add_and_get_attachments() { fn test_add_and_get_attachments() {
let conn = open_test_db(); let conn = open_test_db();
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); upsert_draft(
&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html",
"none", false, false, false,
)
.unwrap();
add_draft_attachment( add_draft_attachment(
&conn, &conn,
@@ -443,8 +492,21 @@ mod tests {
fn test_delete_attachment() { fn test_delete_attachment() {
let conn = open_test_db(); let conn = open_test_db();
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); upsert_draft(
add_draft_attachment(&conn, "att-1", "draft-1", "file.pdf", "application/pdf", 100, "/path").unwrap(); &conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html",
"none", false, false, false,
)
.unwrap();
add_draft_attachment(
&conn,
"att-1",
"draft-1",
"file.pdf",
"application/pdf",
100,
"/path",
)
.unwrap();
let deleted = delete_draft_attachment(&conn, "att-1").unwrap(); let deleted = delete_draft_attachment(&conn, "att-1").unwrap();
assert!(deleted); assert!(deleted);
@@ -457,9 +519,31 @@ mod tests {
fn test_cascade_delete_attachments_on_draft_delete() { fn test_cascade_delete_attachments_on_draft_delete() {
let conn = open_test_db(); let conn = open_test_db();
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); upsert_draft(
add_draft_attachment(&conn, "att-1", "draft-1", "file.pdf", "application/pdf", 100, "/path").unwrap(); &conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html",
add_draft_attachment(&conn, "att-2", "draft-1", "pic.jpg", "image/jpeg", 200, "/path2").unwrap(); "none", false, false, false,
)
.unwrap();
add_draft_attachment(
&conn,
"att-1",
"draft-1",
"file.pdf",
"application/pdf",
100,
"/path",
)
.unwrap();
add_draft_attachment(
&conn,
"att-2",
"draft-1",
"pic.jpg",
"image/jpeg",
200,
"/path2",
)
.unwrap();
delete_draft(&conn, "draft-1").unwrap(); delete_draft(&conn, "draft-1").unwrap();
+117 -82
View File
@@ -1,7 +1,6 @@
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize; use serde::Serialize;
/// A cached IMAP folder, mirroring the `folders` table schema.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct CachedFolder { pub struct CachedFolder {
pub name: String, pub name: String,
@@ -15,7 +14,6 @@ pub struct CachedFolder {
pub highest_modseq: u64, pub highest_modseq: u64,
} }
/// Insert or replace a folder row in the `folders` table.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn upsert_folder( pub fn upsert_folder(
conn: &Connection, conn: &Connection,
@@ -50,11 +48,6 @@ pub fn upsert_folder(
Ok(()) Ok(())
} }
/// Insert a folder only if it doesn't already exist.
///
/// Uses `INSERT OR IGNORE` to avoid triggering `ON DELETE CASCADE` on the
/// messages table (which `INSERT OR REPLACE` would do since it is internally
/// a DELETE + INSERT).
pub fn insert_folder_if_new( pub fn insert_folder_if_new(
conn: &Connection, conn: &Connection,
folder_name: &str, folder_name: &str,
@@ -71,7 +64,6 @@ pub fn insert_folder_if_new(
Ok(()) Ok(())
} }
/// Return all cached folders, sorted alphabetically by name.
pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> { pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
@@ -107,24 +99,15 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
Ok(folders) Ok(folders)
} }
/// Delete folders whose names are NOT in the provided `current_names` list. pub fn remove_stale_folders(conn: &Connection, current_names: &[String]) -> Result<usize, String> {
/// Returns the number of deleted rows.
pub fn remove_stale_folders(
conn: &Connection,
current_names: &[String],
) -> Result<usize, String> {
if current_names.is_empty() { if current_names.is_empty() {
// Delete all folders when the current list is empty.
let deleted = conn let deleted = conn
.execute("DELETE FROM folders", []) .execute("DELETE FROM folders", [])
.map_err(|e| format!("Failed to delete all folders: {e}"))?; .map_err(|e| format!("Failed to delete all folders: {e}"))?;
return Ok(deleted); return Ok(deleted);
} }
// Build a parameterized WHERE NOT IN clause. let placeholders: Vec<String> = (1..=current_names.len()).map(|i| format!("?{i}")).collect();
let placeholders: Vec<String> = (1..=current_names.len())
.map(|i| format!("?{i}"))
.collect();
let sql = format!( let sql = format!(
"DELETE FROM folders WHERE name NOT IN ({})", "DELETE FROM folders WHERE name NOT IN ({})",
placeholders.join(", ") placeholders.join(", ")
@@ -142,18 +125,12 @@ pub fn remove_stale_folders(
Ok(deleted) Ok(deleted)
} }
/// Touch `updated_at` on all folders so the folder-list cache TTL resets.
pub fn touch_all_folders(conn: &Connection) -> Result<(), String> { pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
conn.execute( conn.execute("UPDATE folders SET updated_at = datetime('now')", [])
"UPDATE folders SET updated_at = datetime('now')", .map_err(|e| format!("Failed to touch folders: {e}"))?;
[],
)
.map_err(|e| format!("Failed to touch folders: {e}"))?;
Ok(()) Ok(())
} }
/// Update only the uid_validity and total_count for a folder (without CASCADE side-effects).
/// Also touches `messages_updated_at` to mark when this folder's messages were last synced.
pub fn update_folder_status( pub fn update_folder_status(
conn: &Connection, conn: &Connection,
name: &str, name: &str,
@@ -169,9 +146,6 @@ pub fn update_folder_status(
Ok(()) Ok(())
} }
/// Update server-side folder metadata after a CONDSTORE or STATUS check.
/// Does not touch `messages_updated_at`: metadata alone does not mean the
/// local message cache has been synchronized.
pub fn update_folder_sync_status( pub fn update_folder_sync_status(
conn: &Connection, conn: &Connection,
name: &str, name: &str,
@@ -188,7 +162,6 @@ pub fn update_folder_sync_status(
Ok(()) Ok(())
} }
/// Recompute and store the unread count for a folder from the messages table.
pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> { pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> {
let unread: u32 = conn let unread: u32 = conn
.query_row( .query_row(
@@ -207,8 +180,6 @@ pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(),
Ok(()) Ok(())
} }
/// Adjust a folder's unread count by a signed delta (positive to increase, negative to decrease).
/// Clamps to zero to avoid negative counts.
pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> { pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> {
conn.execute( conn.execute(
"UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2", "UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2",
@@ -218,8 +189,6 @@ pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) ->
Ok(()) Ok(())
} }
/// Check whether any folder was updated within the last `max_age_secs` seconds.
/// Returns `true` if the cache is still fresh, `false` if stale or empty.
pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result<bool, String> { pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result<bool, String> {
let fresh: bool = conn let fresh: bool = conn
.query_row( .query_row(
@@ -234,10 +203,11 @@ pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result<boo
Ok(fresh) Ok(fresh)
} }
/// Check whether a specific folder's messages were synced within the last `max_age_secs` seconds. pub fn is_folder_fresh(
/// Uses `messages_updated_at` which is set by `update_folder_status` (message sync), conn: &Connection,
/// independent of `updated_at` which tracks folder-list sync. folder_name: &str,
pub fn is_folder_fresh(conn: &Connection, folder_name: &str, max_age_secs: u32) -> Result<bool, String> { max_age_secs: u32,
) -> Result<bool, String> {
let fresh: bool = conn let fresh: bool = conn
.query_row( .query_row(
"SELECT EXISTS( "SELECT EXISTS(
@@ -251,10 +221,10 @@ pub fn is_folder_fresh(conn: &Connection, folder_name: &str, max_age_secs: u32)
Ok(fresh) Ok(fresh)
} }
/// Check whether a folder's messages cache has been invalidated pub fn is_folder_messages_invalidated(
/// (i.e., `messages_updated_at IS NULL`), meaning its unread count was conn: &Connection,
/// manually adjusted and should not be recomputed from the messages table. folder_name: &str,
pub fn is_folder_messages_invalidated(conn: &Connection, folder_name: &str) -> Result<bool, String> { ) -> Result<bool, String> {
let invalidated: bool = conn let invalidated: bool = conn
.query_row( .query_row(
"SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1", "SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1",
@@ -265,8 +235,6 @@ pub fn is_folder_messages_invalidated(conn: &Connection, folder_name: &str) -> R
Ok(invalidated) Ok(invalidated)
} }
/// Clear a folder's `messages_updated_at` so the next `is_folder_fresh` check
/// returns `false` and forces an IMAP resync.
pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Result<(), String> { pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute( conn.execute(
"UPDATE folders SET messages_updated_at = NULL WHERE name = ?1", "UPDATE folders SET messages_updated_at = NULL WHERE name = ?1",
@@ -276,7 +244,6 @@ pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Resu
Ok(()) Ok(())
} }
/// Return a single folder by name, or `None` if not found.
pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>, String> { pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>, String> {
let result = conn.query_row( let result = conn.query_row(
"SELECT name, delimiter, parent, flags, is_subscribed, "SELECT name, delimiter, parent, flags, is_subscribed,
@@ -308,18 +275,25 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
} }
} }
/// Delete a folder and all its cached messages.
pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> { pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute("DELETE FROM messages WHERE folder = ?1", params![folder_name]) conn.execute(
.map_err(|e| format!("Failed to delete folder messages: {e}"))?; "DELETE FROM messages WHERE folder = ?1",
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("DELETE FROM folders WHERE name = ?1", params![folder_name])
.map_err(|e| format!("Failed to delete folder: {e}"))?; .map_err(|e| format!("Failed to delete folder: {e}"))?;
Ok(()) Ok(())
} }
/// Rename a folder in the cache, updating both folders and messages tables. pub fn rename_folder_in_cache(
pub fn rename_folder_in_cache(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> { conn: &Connection,
let tx = conn.unchecked_transaction().map_err(|e| format!("Failed to start folder rename transaction: {e}"))?; old_name: &str,
new_name: &str,
) -> Result<(), String> {
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("PRAGMA defer_foreign_keys = ON;")
.map_err(|e| format!("Failed to defer folder rename foreign keys: {e}"))?; .map_err(|e| format!("Failed to defer folder rename foreign keys: {e}"))?;
@@ -333,23 +307,37 @@ pub fn rename_folder_in_cache(conn: &Connection, old_name: &str, new_name: &str)
return Err(format!("Folder not found in cache: {old_name}")); return Err(format!("Folder not found in cache: {old_name}"));
} }
tx.execute("UPDATE messages SET folder = ?1 WHERE folder = ?2", params![new_name, old_name]) tx.execute(
.map_err(|e| format!("Failed to rename messages folder: {e}"))?; "UPDATE messages SET folder = ?1 WHERE folder = ?2",
tx.execute("UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2", params![new_name, old_name]) 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", params![new_name, old_name]) .map_err(|e| format!("Failed to rename messages folder: {e}"))?;
.ok(); tx.execute(
tx.execute("UPDATE folders SET parent = ?1 WHERE parent = ?2", params![new_name, old_name]) "UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2",
.map_err(|e| format!("Failed to update child folder parent: {e}"))?; 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",
params![new_name, old_name],
)
.ok();
tx.execute(
"UPDATE folders SET parent = ?1 WHERE parent = ?2",
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("DELETE FROM folders WHERE name = ?1", params![old_name])
.map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?; .map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?;
tx.commit().map_err(|e| format!("Failed to commit folder rename: {e}"))?; tx.commit()
.map_err(|e| format!("Failed to commit folder rename: {e}"))?;
Ok(()) Ok(())
} }
/// Find a folder whose `flags` column contains the given attribute (e.g. `\Drafts`, `\Sent`). pub fn find_folder_by_attribute(
/// Returns the folder name if found. conn: &Connection,
pub fn find_folder_by_attribute(conn: &Connection, attribute: &str) -> Result<Option<String>, String> { attribute: &str,
) -> Result<Option<String>, String> {
conn.query_row( conn.query_row(
"SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1", "SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1",
[attribute], [attribute],
@@ -368,15 +356,36 @@ mod tests {
fn test_upsert_and_get_folders() { fn test_upsert_and_get_folders() {
let conn = open_test_db(); let conn = open_test_db();
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 42, 5, 100, 200) upsert_folder(
.unwrap(); &conn,
upsert_folder(&conn, "Sent", Some("/"), None, "\\Sent", true, 10, 0, 101, 300) "INBOX",
.unwrap(); Some("/"),
None,
"\\HasNoChildren",
true,
42,
5,
100,
200,
)
.unwrap();
upsert_folder(
&conn,
"Sent",
Some("/"),
None,
"\\Sent",
true,
10,
0,
101,
300,
)
.unwrap();
let folders = get_all_folders(&conn).unwrap(); let folders = get_all_folders(&conn).unwrap();
assert_eq!(folders.len(), 2); assert_eq!(folders.len(), 2);
// Sorted alphabetically: INBOX < Sent
assert_eq!(folders[0].name, "INBOX"); assert_eq!(folders[0].name, "INBOX");
assert_eq!(folders[0].total_count, 42); assert_eq!(folders[0].total_count, 42);
assert_eq!(folders[0].unread_count, 5); assert_eq!(folders[0].unread_count, 5);
@@ -390,12 +399,33 @@ mod tests {
fn test_upsert_updates_existing_folder() { fn test_upsert_updates_existing_folder() {
let conn = open_test_db(); let conn = open_test_db();
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 10, 2, 100, 50) upsert_folder(
.unwrap(); &conn,
"INBOX",
Some("/"),
None,
"\\HasNoChildren",
true,
10,
2,
100,
50,
)
.unwrap();
// Upsert again with different counts. upsert_folder(
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 99, 33, 100, 75) &conn,
.unwrap(); "INBOX",
Some("/"),
None,
"\\HasNoChildren",
true,
99,
33,
100,
75,
)
.unwrap();
let folders = get_all_folders(&conn).unwrap(); let folders = get_all_folders(&conn).unwrap();
assert_eq!(folders.len(), 1); assert_eq!(folders.len(), 1);
@@ -416,7 +446,10 @@ mod tests {
assert!(is_folder_messages_invalidated(&conn, "Sent").unwrap()); assert!(is_folder_messages_invalidated(&conn, "Sent").unwrap());
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap()); assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
assert_eq!(crate::db::messages::count_messages(&conn, "Sent").unwrap(), 0); assert_eq!(
crate::db::messages::count_messages(&conn, "Sent").unwrap(),
0
);
let folder = get_folder(&conn, "Sent").unwrap().unwrap(); let folder = get_folder(&conn, "Sent").unwrap().unwrap();
assert_eq!(folder.uid_validity, 2); assert_eq!(folder.uid_validity, 2);
assert_eq!(folder.total_count, 1); assert_eq!(folder.total_count, 1);
@@ -430,15 +463,18 @@ mod tests {
conn.execute( conn.execute(
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'", "UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'",
[], [],
).unwrap(); )
.unwrap();
update_folder_sync_status(&conn, "Sent", 1, 5, 42).unwrap(); update_folder_sync_status(&conn, "Sent", 1, 5, 42).unwrap();
let timestamp: String = conn.query_row( let timestamp: String = conn
"SELECT messages_updated_at FROM folders WHERE name = 'Sent'", .query_row(
[], "SELECT messages_updated_at FROM folders WHERE name = 'Sent'",
|row| row.get(0), [],
).unwrap(); |row| row.get(0),
)
.unwrap();
assert_eq!(timestamp, "2000-01-01 00:00:00"); assert_eq!(timestamp, "2000-01-01 00:00:00");
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap()); assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
} }
@@ -451,7 +487,6 @@ mod tests {
upsert_folder(&conn, "Sent", None, None, "", true, 0, 0, 0, 0).unwrap(); upsert_folder(&conn, "Sent", None, None, "", true, 0, 0, 0, 0).unwrap();
upsert_folder(&conn, "Trash", None, None, "", true, 0, 0, 0, 0).unwrap(); upsert_folder(&conn, "Trash", None, None, "", true, 0, 0, 0, 0).unwrap();
// Keep only INBOX and Sent; Trash should be removed.
let current = vec!["INBOX".to_string(), "Sent".to_string()]; let current = vec!["INBOX".to_string(), "Sent".to_string()];
let deleted = remove_stale_folders(&conn, &current).unwrap(); let deleted = remove_stale_folders(&conn, &current).unwrap();
assert_eq!(deleted, 1); assert_eq!(deleted, 1);
-9
View File
@@ -1,9 +1,3 @@
//! Reject header batches whose IMAP reads overlap a completed cache removal.
//!
//! The generation is per user database, not per UID: a later sync may discover
//! new destination UIDs or reuse UIDs after UIDVALIDITY changes. No tombstones
//! or locks held over network calls are needed.
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior}; use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior};
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -12,7 +6,6 @@ pub(crate) struct HeaderSync {
} }
impl HeaderSync { impl HeaderSync {
/// Capture before reading IMAP state, and retain for the whole sync pass.
pub(crate) fn capture(conn: &Connection) -> Result<Self, String> { pub(crate) fn capture(conn: &Connection) -> Result<Self, String> {
let generation = conn let generation = conn
.query_row( .query_row(
@@ -28,7 +21,6 @@ impl HeaderSync {
Ok(Self { generation }) Ok(Self { generation })
} }
/// Serialize the check and all header/index/status writes with removals.
pub(crate) fn begin_write(self, conn: &Connection) -> Result<Transaction<'_>, String> { pub(crate) fn begin_write(self, conn: &Connection) -> Result<Transaction<'_>, String> {
let transaction = Transaction::new_unchecked(conn, TransactionBehavior::Immediate) let transaction = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -39,7 +31,6 @@ impl HeaderSync {
} }
} }
/// Must run in the same write transaction as the removal or UIDVALIDITY reset.
pub(crate) fn invalidate(conn: &Transaction<'_>) -> Result<(), String> { pub(crate) fn invalidate(conn: &Transaction<'_>) -> Result<(), String> {
conn.execute( conn.execute(
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1') "INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
+311 -170
View File
@@ -1,9 +1,6 @@
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use serde::Serialize; use serde::Serialize;
// mail_parser is used for RFC2822 date parsing in parse_date_to_epoch.
/// Message data needed to rebuild the full-text search index from SQLite.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct IndexSourceMessage { pub struct IndexSourceMessage {
pub uid: u32, pub uid: u32,
@@ -17,8 +14,6 @@ pub struct IndexSourceMessage {
pub has_attachments: bool, pub has_attachments: bool,
} }
/// A cached email message header, mirroring the query-visible columns of the
/// `messages` table.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct CachedMessage { pub struct CachedMessage {
pub uid: u32, pub uid: u32,
@@ -39,10 +34,6 @@ pub struct CachedMessage {
pub reaction: Option<String>, pub reaction: Option<String>,
} }
// ---------------------------------------------------------------------------
// Helper to map a row to CachedMessage (used in multiple queries)
// ---------------------------------------------------------------------------
fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<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(12)?;
Ok(CachedMessage { Ok(CachedMessage {
@@ -65,17 +56,10 @@ fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMess
}) })
} }
/// The SELECT column list used by all queries that return `CachedMessage`. const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header,
const MSG_SELECT_COLS: &str =
"uid, folder, message_id, in_reply_to, references_header,
subject, from_address, from_name, to_addresses, cc_addresses, subject, from_address, from_name, to_addresses, cc_addresses,
date, flags, has_attachments, size, snippet, reaction"; date, flags, has_attachments, size, snippet, reaction";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Insert or replace a message header row.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn upsert_message( pub fn upsert_message(
conn: &Connection, conn: &Connection,
@@ -127,8 +111,6 @@ pub fn upsert_message(
Ok(()) Ok(())
} }
/// Remove a successfully moved/deleted source UID and reject older header reads.
/// Invalidate freshness so a rejected batch is retried by the next list request.
pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> { pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
let transaction = let transaction =
rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
@@ -139,7 +121,6 @@ pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Resul
transaction.commit().map_err(|e| e.to_string()) transaction.commit().map_err(|e| e.to_string())
} }
/// Delete all messages in a folder (used when UIDVALIDITY changes).
pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize, String> { pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize, String> {
let deleted = conn let deleted = conn
.execute("DELETE FROM messages WHERE folder = ?1", params![folder]) .execute("DELETE FROM messages WHERE folder = ?1", params![folder])
@@ -147,7 +128,6 @@ pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize,
Ok(deleted) Ok(deleted)
} }
/// Return the highest cached UID for a folder, or 0 if no messages are cached.
pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> { pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
conn.query_row( conn.query_row(
"SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1", "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1",
@@ -157,9 +137,6 @@ pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
.map_err(|e| format!("Failed to get max uid: {e}")) .map_err(|e| format!("Failed to get max uid: {e}"))
} }
/// Return all cached message data needed for a full Tantivy rebuild.
/// Body text falls back to the message snippet when the full body has not yet
/// been cached, so a rebuild remains useful before every message is opened.
pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMessage>, String> { pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMessage>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
@@ -193,27 +170,22 @@ pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMe
Ok(messages) Ok(messages)
} }
/// Public wrapper to parse a date string to a Unix epoch timestamp.
/// Useful when other modules need to compute `date_epoch` from a raw date string.
pub fn parse_date_to_epoch_public(date: &str) -> i64 { pub fn parse_date_to_epoch_public(date: &str) -> i64 {
parse_date_to_epoch(date) parse_date_to_epoch(date)
} }
/// Parse a date string to a Unix epoch timestamp (seconds).
/// Tries RFC2822 first (IMAP dates), then ISO 8601.
/// Returns 0 on parse failure.
fn parse_date_to_epoch(date: &str) -> i64 { fn parse_date_to_epoch(date: &str) -> i64 {
if date.is_empty() { if date.is_empty() {
return 0; return 0;
} }
// Try ISO 8601 format first (used in test fixtures and some IMAP servers).
if let Ok(epoch) = parse_iso8601_to_epoch(date) { if let Ok(epoch) = parse_iso8601_to_epoch(date) {
return epoch; return epoch;
} }
// Try mail-parser's RFC2822 date parsing (standard IMAP date format).
if let Some(dt) = mail_parser::DateTime::parse_rfc822(date) { if let Some(dt) = mail_parser::DateTime::parse_rfc822(date) {
let epoch = datetime_to_epoch(&dt); let epoch = datetime_to_epoch(&dt);
// Sanity check: mail_parser can misparse non-RFC2822 dates.
if dt.year >= 1970 && dt.month >= 1 && dt.month <= 12 && epoch > 0 { if dt.year >= 1970 && dt.month >= 1 && dt.month <= 12 && epoch > 0 {
return epoch; return epoch;
} }
@@ -221,9 +193,7 @@ fn parse_date_to_epoch(date: &str) -> i64 {
0 0
} }
/// Convert a mail_parser::DateTime to a Unix epoch timestamp.
fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 { fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
// Days from year 1970 to the start of the given year
let year = dt.year as i64; let year = dt.year as i64;
let mut days: i64 = 0; let mut days: i64 = 0;
for y in 1970..year { for y in 1970..year {
@@ -232,7 +202,7 @@ fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
for y in (year..1970).rev() { for y in (year..1970).rev() {
days -= if is_leap_year(y) { 366 } else { 365 }; days -= if is_leap_year(y) { 366 } else { 365 };
} }
// Days from start of year to start of month
let month_days: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; let month_days: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let month = (dt.month as usize).saturating_sub(1).min(11); let month = (dt.month as usize).saturating_sub(1).min(11);
for (m, &d) in month_days.iter().enumerate().take(month) { for (m, &d) in month_days.iter().enumerate().take(month) {
@@ -243,8 +213,9 @@ fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
} }
days += (dt.day as i64) - 1; days += (dt.day as i64) - 1;
let secs = days * 86400 + (dt.hour as i64) * 3600 + (dt.minute as i64) * 60 + (dt.second as i64); let secs =
// tz_before_gmt=true means the offset is negative (behind UTC) days * 86400 + (dt.hour as i64) * 3600 + (dt.minute as i64) * 60 + (dt.second as i64);
let tz_offset_secs = (dt.tz_hour as i64) * 3600 + (dt.tz_minute as i64) * 60; let tz_offset_secs = (dt.tz_hour as i64) * 3600 + (dt.tz_minute as i64) * 60;
if dt.tz_before_gmt { if dt.tz_before_gmt {
secs + tz_offset_secs secs + tz_offset_secs
@@ -257,9 +228,7 @@ fn is_leap_year(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0 (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
} }
/// Parse ISO 8601 dates like "2024-01-01T10:00:00Z"
fn parse_iso8601_to_epoch(s: &str) -> Result<i64, ()> { fn parse_iso8601_to_epoch(s: &str) -> Result<i64, ()> {
// Simple parser for YYYY-MM-DDTHH:MM:SSZ
let s = s.trim(); let s = s.trim();
if s.len() < 19 { if s.len() < 19 {
return Err(()); return Err(());
@@ -285,8 +254,6 @@ fn parse_iso8601_to_epoch(s: &str) -> Result<i64, ()> {
Ok(datetime_to_epoch(&dt)) Ok(datetime_to_epoch(&dt))
} }
/// Return a page of messages for a folder, ordered by date descending.
/// `page` is 0-indexed.
pub fn get_messages( pub fn get_messages(
conn: &Connection, conn: &Connection,
folder: &str, folder: &str,
@@ -319,10 +286,6 @@ pub fn get_messages(
Ok(messages) Ok(messages)
} }
/// Return a page of messages with a validated sort order.
///
/// `sort` is mapped to a static SQL fragment; user input is never interpolated
/// into the ORDER BY clause.
pub fn get_messages_sorted( pub fn get_messages_sorted(
conn: &Connection, conn: &Connection,
folder: &str, folder: &str,
@@ -339,7 +302,9 @@ pub fn get_messages_sorted(
"date_desc" => "date_epoch DESC, uid DESC", "date_desc" => "date_epoch DESC, uid DESC",
"date_asc" => "date_epoch ASC, uid ASC", "date_asc" => "date_epoch ASC, uid ASC",
"sender_asc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) ASC, date_epoch DESC", "sender_asc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) ASC, date_epoch DESC",
"sender_desc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) DESC, date_epoch DESC", "sender_desc" => {
"LOWER(COALESCE(NULLIF(from_name, ''), from_address)) DESC, date_epoch DESC"
}
"subject_asc" => "LOWER(subject) ASC, date_epoch DESC", "subject_asc" => "LOWER(subject) ASC, date_epoch DESC",
"subject_desc" => "LOWER(subject) DESC, date_epoch DESC", "subject_desc" => "LOWER(subject) DESC, date_epoch DESC",
"size_asc" => "size ASC, date_epoch DESC", "size_asc" => "size ASC, date_epoch DESC",
@@ -370,7 +335,6 @@ pub fn get_messages_sorted(
.map_err(|e| format!("Failed to read sorted message row: {e}")) .map_err(|e| format!("Failed to read sorted message row: {e}"))
} }
/// Return the total number of cached messages for a folder.
pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> { pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
conn.query_row( conn.query_row(
"SELECT COUNT(*) FROM messages WHERE folder = ?1", "SELECT COUNT(*) FROM messages WHERE folder = ?1",
@@ -380,9 +344,6 @@ pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
.map_err(|e| format!("Failed to count messages: {e}")) .map_err(|e| format!("Failed to count messages: {e}"))
} }
/// Return the total number of cached messages across all folders.
/// Used as a safe upper bound when the UI explicitly asks to materialize
/// every result of a search for a bulk operation.
pub fn count_all_messages(conn: &Connection) -> Result<usize, String> { pub fn count_all_messages(conn: &Connection) -> Result<usize, String> {
let count: i64 = conn let count: i64 = conn
.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
@@ -390,7 +351,6 @@ pub fn count_all_messages(conn: &Connection) -> Result<usize, String> {
usize::try_from(count).map_err(|_| "Cached message count is out of range".to_string()) usize::try_from(count).map_err(|_| "Cached message count is out of range".to_string())
} }
/// Update only the flags column for a specific message.
pub fn update_message_flags( pub fn update_message_flags(
conn: &Connection, conn: &Connection,
folder: &str, folder: &str,
@@ -405,8 +365,6 @@ pub fn update_message_flags(
Ok(()) Ok(())
} }
/// Cache a message body (HTML and/or plain text) along with attachment
/// metadata (as a JSON string) and the raw RFC-822 headers.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn cache_message_body( pub fn cache_message_body(
conn: &Connection, conn: &Connection,
@@ -423,7 +381,15 @@ pub fn cache_message_body(
SET body_html = ?1, body_text = ?2, body_cached = 1, SET body_html = ?1, body_text = ?2, body_cached = 1,
attachments_json = ?3, raw_headers = ?4, email_theme = ?5 attachments_json = ?3, raw_headers = ?4, email_theme = ?5
WHERE folder = ?6 AND uid = ?7", WHERE folder = ?6 AND uid = ?7",
params![html, text, attachments_json, raw_headers, email_theme, folder, uid], params![
html,
text,
attachments_json,
raw_headers,
email_theme,
folder,
uid
],
) )
.map_err(|e| format!("Failed to cache message body: {e}"))?; .map_err(|e| format!("Failed to cache message body: {e}"))?;
Ok(()) Ok(())
@@ -443,7 +409,6 @@ pub fn update_email_theme(
Ok(()) Ok(())
} }
/// Cached body data including attachment metadata and raw headers.
pub struct CachedBody { pub struct CachedBody {
pub html: Option<String>, pub html: Option<String>,
pub text: Option<String>, pub text: Option<String>,
@@ -452,7 +417,6 @@ pub struct CachedBody {
pub email_theme: Option<i32>, pub email_theme: Option<i32>,
} }
/// Return the cached body if `body_cached = 1`, otherwise `None`.
pub fn get_cached_body( pub fn get_cached_body(
conn: &Connection, conn: &Connection,
folder: &str, folder: &str,
@@ -486,17 +450,18 @@ pub fn get_cached_body(
} }
} }
/// Return a single message by folder and UID, or None if not found. pub fn get_single_message(
pub fn get_single_message(conn: &Connection, folder: &str, uid: u32) -> Result<Option<CachedMessage>, String> { conn: &Connection,
folder: &str,
uid: u32,
) -> Result<Option<CachedMessage>, String> {
let sql = format!( let sql = format!(
"SELECT {MSG_SELECT_COLS} "SELECT {MSG_SELECT_COLS}
FROM messages FROM messages
WHERE folder = ?1 AND uid = ?2" WHERE folder = ?1 AND uid = ?2"
); );
let result = conn.query_row(&sql, params![folder, uid], |row| { let result = conn.query_row(&sql, params![folder, uid], |row| row_to_cached_message(row));
row_to_cached_message(row)
});
match result { match result {
Ok(msg) => Ok(Some(msg)), Ok(msg) => Ok(Some(msg)),
@@ -505,7 +470,6 @@ pub fn get_single_message(conn: &Connection, folder: &str, uid: u32) -> Result<O
} }
} }
/// Delete a single message by folder and UID.
pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> { pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
conn.execute( conn.execute(
"DELETE FROM messages WHERE folder = ?1 AND uid = ?2", "DELETE FROM messages WHERE folder = ?1 AND uid = ?2",
@@ -515,9 +479,6 @@ pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), S
Ok(()) Ok(())
} }
/// Return `(folder, uid)` pairs for messages that don't have a cached body,
/// ordered by date_epoch DESC (most recent first), limited to `limit`.
/// Used by the deep-index background task to know which bodies to fetch.
pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(String, u32)>, String> { pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(String, u32)>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
@@ -541,8 +502,10 @@ pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(Stri
Ok(result) Ok(result)
} }
/// Return all (uid, flags_csv) pairs for a folder, for reconciliation. pub fn get_all_uids_and_flags(
pub fn get_all_uids_and_flags(conn: &Connection, folder: &str) -> Result<Vec<(u32, String)>, String> { conn: &Connection,
folder: &str,
) -> Result<Vec<(u32, String)>, String> {
let mut stmt = conn let mut stmt = conn
.prepare("SELECT uid, flags FROM messages WHERE folder = ?1") .prepare("SELECT uid, flags FROM messages WHERE folder = ?1")
.map_err(|e| format!("Failed to prepare get_all_uids_and_flags: {e}"))?; .map_err(|e| format!("Failed to prepare get_all_uids_and_flags: {e}"))?;
@@ -559,17 +522,10 @@ pub fn get_all_uids_and_flags(conn: &Connection, folder: &str) -> Result<Vec<(u3
Ok(result) Ok(result)
} }
/// Find messages related to the given `target_message_id` for threading.
///
/// Returns messages where:
/// - `message_id` equals `target_message_id`, OR
/// - `in_reply_to` equals `target_message_id`, OR
/// - `references_header` contains `target_message_id`
pub fn get_thread_messages( pub fn get_thread_messages(
conn: &Connection, conn: &Connection,
target_message_id: &str, target_message_id: &str,
) -> Result<Vec<CachedMessage>, String> { ) -> Result<Vec<CachedMessage>, String> {
// Escape % and _ characters in the message_id to prevent LIKE injection.
let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_"); let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_");
let like_pattern = format!("%{}%", escaped_target); let like_pattern = format!("%{}%", escaped_target);
let sql = format!( let sql = format!(
@@ -594,8 +550,7 @@ pub fn get_thread_messages(
let mut messages = Vec::new(); let mut messages = Vec::new();
for row in rows { for row in rows {
let message = row.map_err(|e| format!("Failed to read thread message row: {e}"))?; let message = row.map_err(|e| format!("Failed to read thread message row: {e}"))?;
// The SQL LIKE narrows candidates but a Message-ID must match a complete
// token in References, never just a substring of another ID.
let exact_reference = message let exact_reference = message
.references_header .references_header
.as_deref() .as_deref()
@@ -610,14 +565,6 @@ pub fn get_thread_messages(
Ok(messages) Ok(messages)
} }
/// Build a complete email thread by transitively walking the References chain.
///
/// Starting from `message_id` and the optional space-separated `references` string,
/// this function repeatedly queries related messages (via `get_thread_messages`) and
/// collects every message_id, in_reply_to, and references_header ID it encounters.
/// The process repeats until no new IDs are discovered (transitive closure).
///
/// The result is deduplicated by `(folder, uid)` and sorted by `date_epoch` ascending.
pub fn get_full_thread( pub fn get_full_thread(
conn: &Connection, conn: &Connection,
message_id: &str, message_id: &str,
@@ -625,7 +572,6 @@ pub fn get_full_thread(
) -> Result<Vec<CachedMessage>, String> { ) -> Result<Vec<CachedMessage>, String> {
use std::collections::HashSet; use std::collections::HashSet;
// Step 1: Seed the set of known message IDs.
let mut known_ids: HashSet<String> = HashSet::new(); let mut known_ids: HashSet<String> = HashSet::new();
known_ids.insert(message_id.to_string()); known_ids.insert(message_id.to_string());
if let Some(refs) = references { if let Some(refs) = references {
@@ -636,16 +582,11 @@ pub fn get_full_thread(
} }
} }
// Step 2-4: Iteratively query until no new IDs are found.
let mut processed_ids: HashSet<String> = HashSet::new(); let mut processed_ids: HashSet<String> = HashSet::new();
let mut all_messages: Vec<CachedMessage> = Vec::new(); let mut all_messages: Vec<CachedMessage> = Vec::new();
loop { loop {
// Find IDs we haven't queried yet. let to_query: Vec<String> = known_ids.difference(&processed_ids).cloned().collect();
let to_query: Vec<String> = known_ids
.difference(&processed_ids)
.cloned()
.collect();
if to_query.is_empty() { if to_query.is_empty() {
break; break;
@@ -656,7 +597,6 @@ pub fn get_full_thread(
let found = get_thread_messages(conn, id)?; let found = get_thread_messages(conn, id)?;
for msg in found { for msg in found {
// Extract IDs from each found message and add to known set.
if let Some(ref mid) = msg.message_id if let Some(ref mid) = msg.message_id
&& !mid.is_empty() && !mid.is_empty()
{ {
@@ -679,19 +619,14 @@ pub fn get_full_thread(
} }
} }
// Step 5: Deduplicate by (folder, uid) pair.
let mut seen: HashSet<(String, u32)> = HashSet::new(); let mut seen: HashSet<(String, u32)> = HashSet::new();
all_messages.retain(|msg| seen.insert((msg.folder.clone(), msg.uid))); all_messages.retain(|msg| seen.insert((msg.folder.clone(), msg.uid)));
// Step 6: Sort by date_epoch ascending.
all_messages.sort_by_key(|msg| parse_date_to_epoch(&msg.date)); all_messages.sort_by_key(|msg| parse_date_to_epoch(&msg.date));
Ok(all_messages) Ok(all_messages)
} }
/// Search the SQLite message cache using LIKE for text matches across
/// subject, from_name, from_address, and to_addresses.
/// This provides comprehensive results independent of the tantivy index state.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn search_messages_sqlite( pub fn search_messages_sqlite(
conn: &Connection, conn: &Connection,
@@ -711,7 +646,6 @@ pub fn search_messages_sqlite(
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
let mut idx = 1u32; let mut idx = 1u32;
// Exclude Trash/Spam/Junk from search results (same as tantivy).
conditions.push("LOWER(folder) NOT IN ('trash', 'spam', 'junk')".to_string()); conditions.push("LOWER(folder) NOT IN ('trash', 'spam', 'junk')".to_string());
if !text.is_empty() { if !text.is_empty() {
@@ -741,7 +675,11 @@ pub fn search_messages_sqlite(
} else { } else {
conditions.push(format!("from_name LIKE ?{idx}")); conditions.push(format!("from_name LIKE ?{idx}"));
} }
let val = if f.contains('@') { f.to_string() } else { format!("%{f}%") }; let val = if f.contains('@') {
f.to_string()
} else {
format!("%{f}%")
};
param_values.push(Box::new(val)); param_values.push(Box::new(val));
idx += 1; idx += 1;
} }
@@ -786,7 +724,7 @@ pub fn search_messages_sqlite(
}); });
} }
let _ = idx; // suppress unused warning let _ = idx;
let where_clause = if conditions.is_empty() { let where_clause = if conditions.is_empty() {
String::new() String::new()
@@ -799,12 +737,14 @@ pub fn search_messages_sqlite(
FROM messages FROM messages
{where_clause} {where_clause}
ORDER BY date_epoch DESC ORDER BY date_epoch DESC
LIMIT ?{}", param_values.len() + 1 LIMIT ?{}",
param_values.len() + 1
); );
param_values.push(Box::new(limit as i64)); param_values.push(Box::new(limit as i64));
let params_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|b| b.as_ref()).collect(); let params_refs: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn let mut stmt = conn
.prepare(&sql) .prepare(&sql)
@@ -829,12 +769,10 @@ mod tests {
use crate::db::folders::upsert_folder; use crate::db::folders::upsert_folder;
use crate::db::pool::open_test_db; use crate::db::pool::open_test_db;
/// Helper: insert a folder so that the foreign key constraint is satisfied.
fn ensure_folder(conn: &Connection, name: &str) { fn ensure_folder(conn: &Connection, name: &str) {
upsert_folder(conn, name, Some("/"), None, "", true, 0, 0, 1, 0).unwrap(); upsert_folder(conn, name, Some("/"), None, "", true, 0, 0, 1, 0).unwrap();
} }
/// Helper: insert a sample message.
fn insert_sample(conn: &Connection, folder: &str, uid: u32, date: &str) { fn insert_sample(conn: &Connection, folder: &str, uid: u32, date: &str) {
upsert_message( upsert_message(
conn, conn,
@@ -880,12 +818,10 @@ mod tests {
assert!(e2 > e1, "date 2 ({e2}) should be after date 1 ({e1})"); assert!(e2 > e1, "date 2 ({e2}) should be after date 1 ({e1})");
assert!(e3 > e2, "date 3 ({e3}) should be after date 2 ({e2})"); assert!(e3 > e2, "date 3 ({e3}) should be after date 2 ({e2})");
// RFC2822 format.
let e4 = parse_date_to_epoch("Mon, 1 Jan 2024 10:00:00 +0000"); let e4 = parse_date_to_epoch("Mon, 1 Jan 2024 10:00:00 +0000");
assert!(e4 > 0, "epoch for rfc2822 date should be > 0, got {e4}"); assert!(e4 > 0, "epoch for rfc2822 date should be > 0, got {e4}");
assert_eq!(e1, e4, "ISO and RFC2822 should produce same epoch"); assert_eq!(e1, e4, "ISO and RFC2822 should produce same epoch");
// Empty returns 0.
assert_eq!(parse_date_to_epoch(""), 0); assert_eq!(parse_date_to_epoch(""), 0);
} }
@@ -901,7 +837,6 @@ mod tests {
let msgs = get_messages(&conn, "INBOX", 0, 10).unwrap(); let msgs = get_messages(&conn, "INBOX", 0, 10).unwrap();
assert_eq!(msgs.len(), 3); assert_eq!(msgs.len(), 3);
// Should be date DESC: uid 3, 2, 1.
assert_eq!(msgs[0].uid, 3); assert_eq!(msgs[0].uid, 3);
assert_eq!(msgs[1].uid, 2); assert_eq!(msgs[1].uid, 2);
assert_eq!(msgs[2].uid, 1); assert_eq!(msgs[2].uid, 1);
@@ -913,7 +848,12 @@ mod tests {
ensure_folder(&conn, "INBOX"); ensure_folder(&conn, "INBOX");
for uid in 1..=5 { for uid in 1..=5 {
insert_sample(&conn, "INBOX", uid, &format!("2024-01-{:02}T10:00:00Z", uid)); insert_sample(
&conn,
"INBOX",
uid,
&format!("2024-01-{:02}T10:00:00Z", uid),
);
} }
let page0 = get_messages(&conn, "INBOX", 0, 2).unwrap(); let page0 = get_messages(&conn, "INBOX", 0, 2).unwrap();
@@ -924,7 +864,6 @@ mod tests {
assert_eq!(page1.len(), 2); assert_eq!(page1.len(), 2);
assert_eq!(page2.len(), 1); assert_eq!(page2.len(), 1);
// Verify no UIDs overlap between pages.
let uids0: Vec<u32> = page0.iter().map(|m| m.uid).collect(); let uids0: Vec<u32> = page0.iter().map(|m| m.uid).collect();
let uids1: Vec<u32> = page1.iter().map(|m| m.uid).collect(); let uids1: Vec<u32> = page1.iter().map(|m| m.uid).collect();
let uids2: Vec<u32> = page2.iter().map(|m| m.uid).collect(); let uids2: Vec<u32> = page2.iter().map(|m| m.uid).collect();
@@ -969,7 +908,6 @@ mod tests {
ensure_folder(&conn, "INBOX"); ensure_folder(&conn, "INBOX");
insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z"); insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z");
// Body should not be cached yet.
let body = get_cached_body(&conn, "INBOX", 1).unwrap(); let body = get_cached_body(&conn, "INBOX", 1).unwrap();
assert!(body.is_none()); assert!(body.is_none());
} }
@@ -1011,10 +949,16 @@ mod tests {
assert!(body.is_none()); assert!(body.is_none());
cache_message_body( cache_message_body(
&conn, "INBOX", 1, &conn,
Some("<h1>Test</h1>"), Some("Test"), "INBOX",
None, None, Some(0), 1,
).unwrap(); Some("<h1>Test</h1>"),
Some("Test"),
None,
None,
Some(0),
)
.unwrap();
update_email_theme(&conn, "INBOX", 1, 1).unwrap(); update_email_theme(&conn, "INBOX", 1, 1).unwrap();
@@ -1041,27 +985,51 @@ mod tests {
let conn = open_test_db(); let conn = open_test_db();
ensure_folder(&conn, "INBOX"); ensure_folder(&conn, "INBOX");
// Original message.
upsert_message( upsert_message(
&conn, "INBOX", 1, &conn,
Some("<thread-1@example.com>"), None, None, "INBOX",
"Hello", "alice@example.com", "Alice", "[]", "[]", 1,
"2024-01-01T10:00:00Z", "", 100, false, "", None, Some("<thread-1@example.com>"),
).unwrap(); None,
None,
"Hello",
"alice@example.com",
"Alice",
"[]",
"[]",
"2024-01-01T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
// Reply referencing original via in_reply_to.
upsert_message( upsert_message(
&conn, "INBOX", 2, &conn,
"INBOX",
2,
Some("<reply-1@example.com>"), Some("<reply-1@example.com>"),
Some("<thread-1@example.com>"), Some("<thread-1@example.com>"),
None, None,
"Re: Hello", "bob@example.com", "Bob", "[]", "[]", "Re: Hello",
"2024-01-02T10:00:00Z", "", 200, false, "", None, "bob@example.com",
).unwrap(); "Bob",
"[]",
"[]",
"2024-01-02T10:00:00Z",
"",
200,
false,
"",
None,
)
.unwrap();
let thread = get_thread_messages(&conn, "<thread-1@example.com>").unwrap(); let thread = get_thread_messages(&conn, "<thread-1@example.com>").unwrap();
assert_eq!(thread.len(), 2); assert_eq!(thread.len(), 2);
// ASC order: uid 1 first, uid 2 second.
assert_eq!(thread[0].uid, 1); assert_eq!(thread[0].uid, 1);
assert_eq!(thread[1].uid, 2); assert_eq!(thread[1].uid, 2);
} }
@@ -1071,28 +1039,52 @@ mod tests {
let conn = open_test_db(); let conn = open_test_db();
ensure_folder(&conn, "INBOX"); ensure_folder(&conn, "INBOX");
// Original message.
upsert_message( upsert_message(
&conn, "INBOX", 1, &conn,
Some("<orig@example.com>"), None, None, "INBOX",
"Hello", "alice@example.com", "Alice", "[]", "[]", 1,
"2024-01-01T10:00:00Z", "", 100, false, "", None, Some("<orig@example.com>"),
).unwrap(); None,
None,
"Hello",
"alice@example.com",
"Alice",
"[]",
"[]",
"2024-01-01T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
// A message that references the original only via references_header.
upsert_message( upsert_message(
&conn, "INBOX", 3, &conn,
"INBOX",
3,
Some("<deep-reply@example.com>"), Some("<deep-reply@example.com>"),
Some("<mid@example.com>"), Some("<mid@example.com>"),
Some("<orig@example.com> <mid@example.com>"), Some("<orig@example.com> <mid@example.com>"),
"Re: Re: Hello", "carol@example.com", "Carol", "[]", "[]", "Re: Re: Hello",
"2024-01-03T10:00:00Z", "", 300, false, "", None, "carol@example.com",
).unwrap(); "Carol",
"[]",
"[]",
"2024-01-03T10:00:00Z",
"",
300,
false,
"",
None,
)
.unwrap();
let thread = get_thread_messages(&conn, "<orig@example.com>").unwrap(); let thread = get_thread_messages(&conn, "<orig@example.com>").unwrap();
assert_eq!(thread.len(), 2); assert_eq!(thread.len(), 2);
assert_eq!(thread[0].uid, 1); // matched by message_id assert_eq!(thread[0].uid, 1);
assert_eq!(thread[1].uid, 3); // matched by references_header LIKE assert_eq!(thread[1].uid, 3);
} }
#[test] #[test]
@@ -1101,17 +1093,45 @@ mod tests {
ensure_folder(&conn, "INBOX"); ensure_folder(&conn, "INBOX");
upsert_message( upsert_message(
&conn, "INBOX", 1, &conn,
Some("<a@example.com>"), None, None, "INBOX",
"Original", "alice@example.com", "Alice", "[]", "[]", 1,
"2024-01-01T10:00:00Z", "", 100, false, "", None, Some("<a@example.com>"),
).unwrap(); None,
None,
"Original",
"alice@example.com",
"Alice",
"[]",
"[]",
"2024-01-01T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
upsert_message( upsert_message(
&conn, "INBOX", 2, &conn,
Some("<reply@example.com>"), None, Some("<aa@example.com>"), "INBOX",
"Unrelated", "bob@example.com", "Bob", "[]", "[]", 2,
"2024-01-02T10:00:00Z", "", 100, false, "", None, Some("<reply@example.com>"),
).unwrap(); None,
Some("<aa@example.com>"),
"Unrelated",
"bob@example.com",
"Bob",
"[]",
"[]",
"2024-01-02T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
let thread = get_thread_messages(&conn, "<a@example.com>").unwrap(); let thread = get_thread_messages(&conn, "<a@example.com>").unwrap();
assert_eq!(thread.len(), 1); assert_eq!(thread.len(), 1);
@@ -1123,17 +1143,69 @@ mod tests {
let conn = open_test_db(); let conn = open_test_db();
ensure_folder(&conn, "INBOX"); ensure_folder(&conn, "INBOX");
// Message 1: original upsert_message(
upsert_message(&conn, "INBOX", 1, Some("<a@ex>"), None, None, &conn,
"Hello", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap(); "INBOX",
// Message 2: reply to 1 1,
upsert_message(&conn, "INBOX", 2, Some("<b@ex>"), Some("<a@ex>"), Some("<a@ex>"), Some("<a@ex>"),
"Re: Hello", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap(); None,
// Message 3: reply to 2, references both None,
upsert_message(&conn, "INBOX", 3, Some("<c@ex>"), Some("<b@ex>"), Some("<a@ex> <b@ex>"), "Hello",
"Re: Re: Hello", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap(); "alice@ex",
"Alice",
"[]",
"[]",
"2024-01-01T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
upsert_message(
&conn,
"INBOX",
2,
Some("<b@ex>"),
Some("<a@ex>"),
Some("<a@ex>"),
"Re: Hello",
"bob@ex",
"Bob",
"[]",
"[]",
"2024-01-02T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
upsert_message(
&conn,
"INBOX",
3,
Some("<c@ex>"),
Some("<b@ex>"),
Some("<a@ex> <b@ex>"),
"Re: Re: Hello",
"carol@ex",
"Carol",
"[]",
"[]",
"2024-01-03T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
// Starting from message 2, should find the entire thread (1, 2, 3).
let thread = get_full_thread(&conn, "<b@ex>", Some("<a@ex>")).unwrap(); let thread = get_full_thread(&conn, "<b@ex>", Some("<a@ex>")).unwrap();
assert_eq!(thread.len(), 3); assert_eq!(thread.len(), 3);
assert_eq!(thread[0].uid, 1); assert_eq!(thread[0].uid, 1);
@@ -1146,14 +1218,67 @@ mod tests {
let conn = open_test_db(); let conn = open_test_db();
ensure_folder(&conn, "INBOX"); ensure_folder(&conn, "INBOX");
upsert_message(&conn, "INBOX", 1, Some("<root@ex>"), None, None, upsert_message(
"Start", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap(); &conn,
upsert_message(&conn, "INBOX", 2, Some("<mid@ex>"), Some("<root@ex>"), Some("<root@ex>"), "INBOX",
"Re: Start", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap(); 1,
upsert_message(&conn, "INBOX", 3, Some("<leaf@ex>"), Some("<mid@ex>"), Some("<root@ex> <mid@ex>"), Some("<root@ex>"),
"Re: Re: Start", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap(); None,
None,
"Start",
"alice@ex",
"Alice",
"[]",
"[]",
"2024-01-01T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
upsert_message(
&conn,
"INBOX",
2,
Some("<mid@ex>"),
Some("<root@ex>"),
Some("<root@ex>"),
"Re: Start",
"bob@ex",
"Bob",
"[]",
"[]",
"2024-01-02T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
upsert_message(
&conn,
"INBOX",
3,
Some("<leaf@ex>"),
Some("<mid@ex>"),
Some("<root@ex> <mid@ex>"),
"Re: Re: Start",
"carol@ex",
"Carol",
"[]",
"[]",
"2024-01-03T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
// Starting from the leaf message, should still find entire thread.
let thread = get_full_thread(&conn, "<leaf@ex>", Some("<root@ex> <mid@ex>")).unwrap(); let thread = get_full_thread(&conn, "<leaf@ex>", Some("<root@ex> <mid@ex>")).unwrap();
assert_eq!(thread.len(), 3); assert_eq!(thread.len(), 3);
} }
@@ -1163,8 +1288,26 @@ mod tests {
let conn = open_test_db(); let conn = open_test_db();
ensure_folder(&conn, "INBOX"); ensure_folder(&conn, "INBOX");
upsert_message(&conn, "INBOX", 1, Some("<solo@ex>"), None, None, upsert_message(
"Solo", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap(); &conn,
"INBOX",
1,
Some("<solo@ex>"),
None,
None,
"Solo",
"alice@ex",
"Alice",
"[]",
"[]",
"2024-01-01T10:00:00Z",
"",
100,
false,
"",
None,
)
.unwrap();
let thread = get_full_thread(&conn, "<solo@ex>", None).unwrap(); let thread = get_full_thread(&conn, "<solo@ex>", None).unwrap();
assert_eq!(thread.len(), 1); assert_eq!(thread.len(), 1);
@@ -1172,8 +1315,6 @@ mod tests {
} }
} }
/// Cached storage is an estimate while folders are still being synchronized.
/// None distinguishes an empty cache from cached zero-byte messages.
pub fn cached_storage_bytes(conn: &Connection) -> Result<Option<u64>, String> { pub fn cached_storage_bytes(conn: &Connection) -> Result<Option<u64>, String> {
conn.query_row("SELECT SUM(size) FROM messages", [], |row| row.get(0)) conn.query_row("SELECT SUM(size) FROM messages", [], |row| row.get(0))
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
+22 -17
View File
@@ -22,8 +22,6 @@ pub struct UpdateNotificationPreferences {
pub dnd_end: Option<String>, pub dnd_end: Option<String>,
} }
/// Retrieve the singleton notification preferences row.
/// Returns sensible defaults if the row does not yet exist.
pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, String> { pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, String> {
let result = conn.query_row( let result = conn.query_row(
"SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1", "SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1",
@@ -41,7 +39,15 @@ pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, Str
); );
match result { match result {
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,
)) => {
let folders: Vec<String> = serde_json::from_str(&folders_json) let folders: Vec<String> = serde_json::from_str(&folders_json)
.map_err(|e| format!("Failed to parse folders JSON: {e}"))?; .map_err(|e| format!("Failed to parse folders JSON: {e}"))?;
Ok(NotificationPreferences { Ok(NotificationPreferences {
@@ -67,13 +73,10 @@ pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, Str
} }
} }
/// Update the singleton notification preferences row.
/// Only provided fields are changed. Returns the updated preferences.
pub fn update_preferences( pub fn update_preferences(
conn: &Connection, conn: &Connection,
data: &UpdateNotificationPreferences, data: &UpdateNotificationPreferences,
) -> Result<NotificationPreferences, String> { ) -> Result<NotificationPreferences, String> {
// Ensure the row exists.
conn.execute( conn.execute(
"INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)", "INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)",
[], [],
@@ -95,8 +98,8 @@ pub fn update_preferences(
idx += 1; idx += 1;
} }
if let Some(ref folders) = data.folders { if let Some(ref folders) = data.folders {
let folders_json = let folders_json = serde_json::to_string(folders)
serde_json::to_string(folders).map_err(|e| format!("Failed to serialize folders: {e}"))?; .map_err(|e| format!("Failed to serialize folders: {e}"))?;
sets.push(format!("folders = ?{idx}")); sets.push(format!("folders = ?{idx}"));
values.push(Box::new(folders_json)); values.push(Box::new(folders_json));
idx += 1; idx += 1;
@@ -107,13 +110,17 @@ pub fn update_preferences(
idx += 1; idx += 1;
} }
if let Some(ref dnd_start) = data.dnd_start { if let Some(ref dnd_start) = data.dnd_start {
if !valid_clock(dnd_start) { return Err("Invalid DND start time".to_string()); } if !valid_clock(dnd_start) {
return Err("Invalid DND start time".to_string());
}
sets.push(format!("dnd_start = ?{idx}")); sets.push(format!("dnd_start = ?{idx}"));
values.push(Box::new(dnd_start.clone())); values.push(Box::new(dnd_start.clone()));
idx += 1; idx += 1;
} }
if let Some(ref dnd_end) = data.dnd_end { if let Some(ref dnd_end) = data.dnd_end {
if !valid_clock(dnd_end) { return Err("Invalid DND end time".to_string()); } if !valid_clock(dnd_end) {
return Err("Invalid DND end time".to_string());
}
sets.push(format!("dnd_end = ?{idx}")); sets.push(format!("dnd_end = ?{idx}"));
values.push(Box::new(dnd_end.clone())); values.push(Box::new(dnd_end.clone()));
idx += 1; idx += 1;
@@ -128,8 +135,7 @@ pub fn update_preferences(
let sql = format!("UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}"); let sql = format!("UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}");
values.push(Box::new(1_i32)); values.push(Box::new(1_i32));
let params_refs: Vec<&dyn rusqlite::types::ToSql> = let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect();
values.iter().map(|v| v.as_ref()).collect();
conn.execute(&sql, params_refs.as_slice()) conn.execute(&sql, params_refs.as_slice())
.map_err(|e| format!("Failed to update notification preferences: {e}"))?; .map_err(|e| format!("Failed to update notification preferences: {e}"))?;
@@ -138,7 +144,9 @@ pub fn update_preferences(
} }
fn valid_clock(value: &str) -> bool { fn valid_clock(value: &str) -> bool {
let Some((hour, minute)) = value.split_once(':') else { return false; }; let Some((hour, minute)) = value.split_once(':') else {
return false;
};
matches!((hour.parse::<u8>(), minute.parse::<u8>()), (Ok(h), Ok(m)) if h < 24 && m < 60 && hour.len() == 2 && minute.len() == 2) matches!((hour.parse::<u8>(), minute.parse::<u8>()), (Ok(h), Ok(m)) if h < 24 && m < 60 && hour.len() == 2 && minute.len() == 2)
} }
@@ -197,10 +205,7 @@ mod tests {
.unwrap(); .unwrap();
assert!(prefs.enabled); assert!(prefs.enabled);
assert_eq!( assert_eq!(prefs.folders, vec!["INBOX".to_string(), "Sent".to_string()]);
prefs.folders,
vec!["INBOX".to_string(), "Sent".to_string()]
);
} }
#[test] #[test]
+1 -25
View File
@@ -3,7 +3,6 @@ use std::path::Path;
use rusqlite::Connection; use rusqlite::Connection;
/// Owned database context for short blocking jobs. Never hold SQLite on a Tokio worker.
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct UserDb { pub(crate) struct UserDb {
data_dir: String, data_dir: String,
@@ -32,7 +31,6 @@ impl UserDb {
} }
} }
/// All migration scripts in order. Each entry is (version, sql).
const MIGRATIONS: &[(u32, &str)] = &[ const MIGRATIONS: &[(u32, &str)] = &[
(1, include_str!("../../migrations/V001__initial_schema.sql")), (1, include_str!("../../migrations/V001__initial_schema.sql")),
( (
@@ -199,20 +197,11 @@ const MIGRATIONS: &[(u32, &str)] = &[
), ),
]; ];
/// Run any pending migrations based on SQLite's `user_version` PRAGMA.
///
/// If refinery has previously run migrations (indicated by the presence of
/// `refinery_schema_history`), we sync `user_version` to match the highest
/// refinery version before checking for new migrations. This avoids re-running
/// migrations that refinery already applied.
fn run_migrations(conn: &Connection) -> Result<(), String> { fn run_migrations(conn: &Connection) -> Result<(), String> {
let mut current: u32 = conn let mut current: u32 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0)) .query_row("PRAGMA user_version", [], |row| row.get(0))
.map_err(|e| format!("Failed to read user_version: {e}"))?; .map_err(|e| format!("Failed to read user_version: {e}"))?;
// Sync with refinery's migration state so we never re-apply migrations
// that refinery already applied (refinery updates its own history table
// but doesn't touch the user_version pragma).
let refinery_max: Option<u32> = conn let refinery_max: Option<u32> = conn
.query_row( .query_row(
"SELECT MAX(version) FROM refinery_schema_history", "SELECT MAX(version) FROM refinery_schema_history",
@@ -241,10 +230,6 @@ fn run_migrations(conn: &Connection) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Opens the per-user SQLite database at `{data_dir}/{user_hash}/db.sqlite`.
///
/// Creates the directory tree if it doesn't exist, enables WAL journal mode
/// and foreign key enforcement, then runs any pending migrations.
pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, String> { pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, String> {
let dir = Path::new(data_dir).join(user_hash); let dir = Path::new(data_dir).join(user_hash);
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?; fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?;
@@ -252,23 +237,17 @@ pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, Strin
let db_path = dir.join("db.sqlite"); let db_path = dir.join("db.sqlite");
let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?; let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
// Enable WAL mode for better concurrent read performance.
conn.execute_batch("PRAGMA journal_mode=WAL;") conn.execute_batch("PRAGMA journal_mode=WAL;")
.map_err(|e| format!("Failed to set WAL mode: {e}"))?; .map_err(|e| format!("Failed to set WAL mode: {e}"))?;
// Enable foreign key constraint enforcement.
conn.execute_batch("PRAGMA foreign_keys=ON;") conn.execute_batch("PRAGMA foreign_keys=ON;")
.map_err(|e| format!("Failed to enable foreign keys: {e}"))?; .map_err(|e| format!("Failed to enable foreign keys: {e}"))?;
// Run any pending schema migrations.
run_migrations(&conn)?; run_migrations(&conn)?;
Ok(conn) Ok(conn)
} }
/// Opens an in-memory SQLite database with all migration scripts applied.
/// Used exclusively by tests so every test starts with a clean, fully-migrated
/// schema without touching the filesystem.
#[cfg(test)] #[cfg(test)]
pub fn open_test_db() -> Connection { pub fn open_test_db() -> Connection {
let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite"); let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite");
@@ -289,7 +268,6 @@ mod tests {
fn test_open_test_db_has_tables() { fn test_open_test_db_has_tables() {
let conn = open_test_db(); let conn = open_test_db();
// Verify the three expected tables exist.
let tables: Vec<String> = conn let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.unwrap() .unwrap()
@@ -312,11 +290,9 @@ mod tests {
let conn = open_user_db(data_dir, "abc123").unwrap(); let conn = open_user_db(data_dir, "abc123").unwrap();
// The file should exist on disk.
let db_file = tmp.path().join("abc123").join("db.sqlite"); let db_file = tmp.path().join("abc123").join("db.sqlite");
assert!(db_file.exists()); assert!(db_file.exists());
// Foreign keys should be enabled.
let fk: i32 = conn let fk: i32 = conn
.query_row("PRAGMA foreign_keys", [], |row| row.get(0)) .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
.unwrap(); .unwrap();
@@ -347,7 +323,7 @@ mod async_tests {
.await .await
}); });
ready.await.unwrap(); ready.await.unwrap();
// On a current-thread runtime, an inline DB job prevents this send.
release.send(()).unwrap(); release.send(()).unwrap();
job.await.unwrap().unwrap(); job.await.unwrap().unwrap();
} }
+11 -4
View File
@@ -1,4 +1,4 @@
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const ROLES: &[&str] = &["sent", "drafts", "trash", "junk", "archive"]; const ROLES: &[&str] = &["sent", "drafts", "trash", "junk", "archive"];
@@ -78,7 +78,11 @@ pub fn update_all(conn: &Connection, folders: &SpecialFolders) -> Result<Special
get_all(conn) get_all(conn)
} }
pub fn rename_folder_reference(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> { pub fn rename_folder_reference(
conn: &Connection,
old_name: &str,
new_name: &str,
) -> Result<(), String> {
conn.execute( conn.execute(
"UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2", "UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2",
params![new_name, old_name], params![new_name, old_name],
@@ -88,8 +92,11 @@ pub fn rename_folder_reference(conn: &Connection, old_name: &str, new_name: &str
} }
pub fn clear_folder_reference(conn: &Connection, folder_name: &str) -> Result<(), String> { pub fn clear_folder_reference(conn: &Connection, folder_name: &str) -> Result<(), String> {
conn.execute("DELETE FROM special_folders WHERE folder_name = ?1", [folder_name]) conn.execute(
.map_err(|e| format!("Failed to clear special folder reference: {e}"))?; "DELETE FROM special_folders WHERE folder_name = ?1",
[folder_name],
)
.map_err(|e| format!("Failed to clear special folder reference: {e}"))?;
Ok(()) Ok(())
} }
+51 -50
View File
@@ -1,11 +1,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use rusqlite::{params, Connection}; use rusqlite::{Connection, params};
use serde::Serialize; use serde::Serialize;
use crate::db::messages::CachedMessage; use crate::db::messages::CachedMessage;
/// A tag record, mirroring the `tags` table.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct Tag { pub struct Tag {
pub id: String, pub id: String,
@@ -17,7 +16,6 @@ pub struct Tag {
pub updated_at: String, pub updated_at: String,
} }
/// Lightweight tag info returned alongside messages.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct MessageTag { pub struct MessageTag {
pub id: String, pub id: String,
@@ -25,13 +23,11 @@ pub struct MessageTag {
pub color: String, pub color: String,
} }
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Create a new tag.
pub fn imap_keyword_for_tag_id(id: &str) -> String { pub fn imap_keyword_for_tag_id(id: &str) -> String {
format!("RustBox-{}", id.chars().filter(|ch| *ch != '-').collect::<String>()) format!(
"RustBox-{}",
id.chars().filter(|ch| *ch != '-').collect::<String>()
)
} }
pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<(), String> { pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<(), String> {
@@ -44,7 +40,6 @@ pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
Ok(()) Ok(())
} }
/// List all tags with their message counts.
pub fn list_tags(conn: &Connection) -> Result<Vec<Tag>, String> { pub fn list_tags(conn: &Connection) -> Result<Vec<Tag>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
@@ -81,22 +76,33 @@ pub fn get_tag(conn: &Connection, id: &str) -> Result<Option<Tag>, String> {
Ok(tags.drain(..).find(|tag| tag.id == id)) Ok(tags.drain(..).find(|tag| tag.id == id))
} }
pub fn get_message_refs_by_tag(conn: &Connection, tag_id: &str) -> Result<Vec<(u32, String)>, String> { pub fn get_message_refs_by_tag(
let mut stmt = conn.prepare("SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?1") conn: &Connection,
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")
.map_err(|e| format!("Failed to prepare tag message refs: {e}"))?; .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)?))) let rows = stmt
.query_map(params![tag_id], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|e| format!("Failed to query tag message refs: {e}"))?; .map_err(|e| format!("Failed to query tag message refs: {e}"))?;
let mut refs = Vec::new(); let mut refs = Vec::new();
for row in rows { refs.push(row.map_err(|e| format!("Failed to read tag message ref: {e}"))?); } for row in rows {
refs.push(row.map_err(|e| format!("Failed to read tag message ref: {e}"))?);
}
Ok(refs) Ok(refs)
} }
/// Import RustBox-owned IMAP keyword flags into local message/tag relations. pub fn import_imap_keywords(
/// This intentionally only adds remotely-present labels; absence is not treated as conn: &Connection,
/// deletion because some servers/folders do not expose persistent custom flags. uid: u32,
pub fn import_imap_keywords(conn: &Connection, uid: u32, folder: &str, flags: &[String]) -> Result<usize, String> { folder: &str,
flags: &[String],
) -> Result<usize, String> {
let keyword_set: std::collections::HashSet<&str> = flags.iter().map(String::as_str).collect(); let keyword_set: std::collections::HashSet<&str> = flags.iter().map(String::as_str).collect();
if !keyword_set.iter().any(|flag| flag.starts_with("RustBox-")) { return Ok(0); } if !keyword_set.iter().any(|flag| flag.starts_with("RustBox-")) {
return Ok(0);
}
let tags = list_tags(conn)?; let tags = list_tags(conn)?;
let mut imported = 0usize; let mut imported = 0usize;
for tag in tags { for tag in tags {
@@ -108,14 +114,6 @@ pub fn import_imap_keywords(conn: &Connection, uid: u32, folder: &str, flags: &[
Ok(imported) Ok(imported)
} }
/// Reconcile RustBox-owned IMAP keyword flags for one message.
///
/// This is intentionally stronger than `import_imap_keywords`: callers must
/// use it only after confirming that the selected mailbox supports persistent
/// custom keywords and that `flags` is the complete authoritative FLAGS set.
/// Remotely present RustBox keywords are added locally and remotely absent
/// RustBox keywords are removed locally.
pub fn reconcile_imap_keywords( pub fn reconcile_imap_keywords(
conn: &Connection, conn: &Connection,
uid: u32, uid: u32,
@@ -153,7 +151,6 @@ pub fn reconcile_imap_keywords(
Ok((added, removed)) Ok((added, removed))
} }
/// Update a tag's name and/or color.
pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<bool, String> { pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<bool, String> {
let updated = conn let updated = conn
.execute( .execute(
@@ -164,7 +161,6 @@ pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
Ok(updated > 0) Ok(updated > 0)
} }
/// Delete a tag. CASCADE handles junction rows.
pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> { pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> {
let deleted = conn let deleted = conn
.execute("DELETE FROM tags WHERE id = ?1", params![id]) .execute("DELETE FROM tags WHERE id = ?1", params![id])
@@ -172,7 +168,6 @@ pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> {
Ok(deleted > 0) Ok(deleted > 0)
} }
/// Add a tag to a message (INSERT OR IGNORE to avoid duplicates).
pub fn add_tag_to_message( pub fn add_tag_to_message(
conn: &Connection, conn: &Connection,
tag_id: &str, tag_id: &str,
@@ -187,7 +182,6 @@ pub fn add_tag_to_message(
Ok(()) Ok(())
} }
/// Remove a tag from a message.
pub fn remove_tag_from_message( pub fn remove_tag_from_message(
conn: &Connection, conn: &Connection,
tag_id: &str, tag_id: &str,
@@ -203,8 +197,11 @@ pub fn remove_tag_from_message(
Ok(deleted > 0) Ok(deleted > 0)
} }
/// Get all tags for a specific message. pub fn get_message_tags(
pub fn get_message_tags(conn: &Connection, uid: u32, folder: &str) -> Result<Vec<MessageTag>, String> { conn: &Connection,
uid: u32,
folder: &str,
) -> Result<Vec<MessageTag>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT t.id, t.name, t.color "SELECT t.id, t.name, t.color
@@ -232,7 +229,6 @@ pub fn get_message_tags(conn: &Connection, uid: u32, folder: &str) -> Result<Vec
Ok(tags) Ok(tags)
} }
/// Batch-fetch tags for multiple messages (efficient for list view).
pub fn get_tags_for_messages( pub fn get_tags_for_messages(
conn: &Connection, conn: &Connection,
messages: &[(u32, &str)], messages: &[(u32, &str)],
@@ -241,12 +237,16 @@ pub fn get_tags_for_messages(
return Ok(HashMap::new()); return Ok(HashMap::new());
} }
// Build a query with placeholders for all message refs.
// Using a single query with OR conditions for reasonable batch sizes.
let conditions: Vec<String> = messages let conditions: Vec<String> = messages
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, _)| format!("(mt.message_uid = ?{} AND mt.message_folder = ?{})", i * 2 + 1, i * 2 + 2)) .map(|(i, _)| {
format!(
"(mt.message_uid = ?{} AND mt.message_folder = ?{})",
i * 2 + 1,
i * 2 + 2
)
})
.collect(); .collect();
let sql = format!( let sql = format!(
@@ -267,7 +267,8 @@ pub fn get_tags_for_messages(
param_values.push(Box::new(uid)); param_values.push(Box::new(uid));
param_values.push(Box::new(folder.to_string())); param_values.push(Box::new(folder.to_string()));
} }
let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); let params_ref: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|p| p.as_ref()).collect();
let rows = stmt let rows = stmt
.query_map(params_ref.as_slice(), |row| { .query_map(params_ref.as_slice(), |row| {
@@ -290,7 +291,6 @@ pub fn get_tags_for_messages(
Ok(map) Ok(map)
} }
/// Get messages by tag (paginated), ordered by date descending.
pub fn get_messages_by_tag( pub fn get_messages_by_tag(
conn: &Connection, conn: &Connection,
tag_id: &str, tag_id: &str,
@@ -342,7 +342,6 @@ pub fn get_messages_by_tag(
Ok(messages) Ok(messages)
} }
/// Count messages with a given tag.
pub fn count_messages_by_tag(conn: &Connection, tag_id: &str) -> Result<u32, String> { pub fn count_messages_by_tag(conn: &Connection, tag_id: &str) -> Result<u32, String> {
conn.query_row( conn.query_row(
"SELECT COUNT(*) FROM message_tags WHERE tag_id = ?1", "SELECT COUNT(*) FROM message_tags WHERE tag_id = ?1",
@@ -420,14 +419,21 @@ mod tests {
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap(); add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
add_tag_to_message(&conn, "t2", 1, "INBOX").unwrap(); add_tag_to_message(&conn, "t2", 1, "INBOX").unwrap();
// Duplicate should be ignored
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap(); add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
let tags = get_message_tags(&conn, 1, "INBOX").unwrap(); let tags = get_message_tags(&conn, 1, "INBOX").unwrap();
assert_eq!(tags.len(), 2); assert_eq!(tags.len(), 2);
let tag_list = list_tags(&conn).unwrap(); let tag_list = list_tags(&conn).unwrap();
assert_eq!(tag_list.iter().find(|t| t.name == "Urgent").unwrap().message_count, 1); assert_eq!(
tag_list
.iter()
.find(|t| t.name == "Urgent")
.unwrap()
.message_count,
1
);
} }
#[test] #[test]
@@ -483,13 +489,9 @@ mod tests {
add_tag_to_message(&conn, "t1", 7, "INBOX").unwrap(); add_tag_to_message(&conn, "t1", 7, "INBOX").unwrap();
let keyword_two = imap_keyword_for_tag_id("t2"); let keyword_two = imap_keyword_for_tag_id("t2");
let (added, removed) = reconcile_imap_keywords( let (added, removed) =
&conn, reconcile_imap_keywords(&conn, 7, "INBOX", &["\\Seen".to_string(), keyword_two])
7, .unwrap();
"INBOX",
&["\\Seen".to_string(), keyword_two],
)
.unwrap();
assert_eq!(added, 1); assert_eq!(added, 1);
assert_eq!(removed, 1); assert_eq!(removed, 1);
@@ -497,5 +499,4 @@ mod tests {
assert_eq!(tags.len(), 1); assert_eq!(tags.len(), 1);
assert_eq!(tags[0].id, "t2"); assert_eq!(tags[0].id, "t2");
} }
} }
+1 -70
View File
@@ -53,8 +53,6 @@ static TD_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<td\b[^>]*>"#
static DIV_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<div\b[^>]*>"#).unwrap()); static DIV_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<div\b[^>]*>"#).unwrap());
/// Compiled word-boundary regexes for each named CSS color, ensuring exact
/// matching so that e.g. "white" does not match inside "whitesmoke".
static NAMED_COLOR_REGEXES: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new(|| { static NAMED_COLOR_REGEXES: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new(|| {
NAMED_COLORS NAMED_COLORS
.iter() .iter()
@@ -65,7 +63,6 @@ static NAMED_COLOR_REGEXES: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new
.collect() .collect()
}); });
/// Named CSS colors mapped to RGB values.
type NamedColorList = Vec<(&'static str, (u8, u8, u8))>; type NamedColorList = Vec<(&'static str, (u8, u8, u8))>;
static NAMED_COLORS: LazyLock<NamedColorList> = LazyLock::new(|| { static NAMED_COLORS: LazyLock<NamedColorList> = LazyLock::new(|| {
vec![ vec![
@@ -132,15 +129,6 @@ impl ToSql for EmailTheme {
} }
} }
/// Detect whether an email's HTML is light, dark, transparent, or adaptive.
///
/// Detection priority:
/// 1. If the email has `@media(prefers-color-scheme:dark)` rules with background
/// overrides, classify as Adaptive (it handles dark mode natively).
/// 2. Extract background colors from body, table, td, div elements (inline styles,
/// style tags, bgcolor attrs). Group by luminance (light vs dark). If one group
/// dominates (>= 50% of total weight), return that group's classification.
/// 3. If no backgrounds found, return Transparent.
pub fn detect_email_theme(html: &str) -> Option<EmailTheme> { pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
if html.trim().is_empty() { if html.trim().is_empty() {
return None; return None;
@@ -148,9 +136,6 @@ pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
let decoded = decode_quoted_printable(html); let decoded = decode_quoted_printable(html);
// Check for @media(prefers-color-scheme:dark) with background overrides.
// These emails handle their own dark adaptation — classify as Adaptive
// so the frontend sets color-scheme and lets the email CSS handle it.
if has_dark_mode_media_query(&decoded) { if has_dark_mode_media_query(&decoded) {
return Some(EmailTheme::Adaptive); return Some(EmailTheme::Adaptive);
} }
@@ -164,8 +149,6 @@ pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
classify_by_luminance_groups(&backgrounds) classify_by_luminance_groups(&backgrounds)
} }
/// Check if the HTML has a @media(prefers-color-scheme:dark) block that sets
/// background colors, indicating the email handles dark mode natively.
fn has_dark_mode_media_query(html: &str) -> bool { fn has_dark_mode_media_query(html: &str) -> bool {
let lower = html.to_lowercase(); let lower = html.to_lowercase();
for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) { for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) {
@@ -177,8 +160,6 @@ fn has_dark_mode_media_query(html: &str) -> bool {
false false
} }
/// Group all extracted colors into "light" and "dark" buckets by their
/// luminance, then check which bucket dominates.
fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme> { fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme> {
let mut light_weight: f32 = 0.0; let mut light_weight: f32 = 0.0;
let mut dark_weight: f32 = 0.0; let mut dark_weight: f32 = 0.0;
@@ -205,20 +186,12 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme
let light_coverage = light_weight / total_weight; let light_coverage = light_weight / total_weight;
let dark_coverage = dark_weight / total_weight; let dark_coverage = dark_weight / total_weight;
// Require at least 50% of weighted colors to agree
if light_coverage >= 0.5 { if light_coverage >= 0.5 {
// If the only signal is from a wrapper table/td with white background
// and there's no body-level background, treat as transparent.
// A lone "white" on a wrapper element is usually just a reset, not
// intentional theming.
if !has_body_background && is_only_white_wrapper(colors) { if !has_body_background && is_only_white_wrapper(colors) {
return Some(EmailTheme::Transparent); return Some(EmailTheme::Transparent);
} }
Some(EmailTheme::Light) Some(EmailTheme::Light)
} else if dark_coverage >= 0.5 { } else if dark_coverage >= 0.5 {
// Guard against tiny accent elements being the only signal:
// if total weight is very low (only 1-2 low-weight elements like td),
// that's likely an accent, not a real background.
if total_weight <= 1.0 && colors.len() <= 2 && !has_body_background { if total_weight <= 1.0 && colors.len() <= 2 && !has_body_background {
Some(EmailTheme::Transparent) Some(EmailTheme::Transparent)
} else { } else {
@@ -229,19 +202,15 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme
} }
} }
/// Check if all color signals are just white (#ffffff or named "white") on
/// wrapper elements (table or td) without any body-level background. This
/// usually indicates a boilerplate reset rather than intentional light theming.
fn is_only_white_wrapper(colors: &[BackgroundColor]) -> bool { fn is_only_white_wrapper(colors: &[BackgroundColor]) -> bool {
if colors.is_empty() { if colors.is_empty() {
return false; return false;
} }
for bg in colors { for bg in colors {
// If there's a body-level background, it's intentional
if bg.element_weight >= 1.0 { if bg.element_weight >= 1.0 {
return false; return false;
} }
// Only consider pure white as a wrapper reset
let c = bg.color.trim().to_lowercase(); let c = bg.color.trim().to_lowercase();
if c != "#ffffff" && c != "white" { if c != "#ffffff" && c != "white" {
return false; return false;
@@ -273,21 +242,16 @@ fn extract_background_colors(html: &str) -> Vec<BackgroundColor> {
extract_style_tag_colors(html, &mut colors); extract_style_tag_colors(html, &mut colors);
extract_bgcolor_attrs(html, &mut colors); extract_bgcolor_attrs(html, &mut colors);
// Deduplicate: if the same color appears on the same element from both
// bgcolor and style attributes, keep only the one with higher weight.
deduplicate_colors(&mut colors); deduplicate_colors(&mut colors);
colors colors
} }
/// Remove duplicate color entries that likely come from the same element
/// having both bgcolor="X" and style="background-color:X".
fn deduplicate_colors(colors: &mut Vec<BackgroundColor>) { fn deduplicate_colors(colors: &mut Vec<BackgroundColor>) {
if colors.len() <= 1 { if colors.len() <= 1 {
return; return;
} }
// Track seen (normalized_color, weight) pairs and remove exact duplicates
let mut seen: HashSet<String> = HashSet::new(); let mut seen: HashSet<String> = HashSet::new();
colors.retain(|bg| { colors.retain(|bg| {
let key = format!("{}:{:.1}", bg.color, bg.element_weight); let key = format!("{}:{:.1}", bg.color, bg.element_weight);
@@ -334,7 +298,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
continue; continue;
} }
// Extract from body{...} rules
if let Some(body_cap) = BODY_CSS_RE.captures(style_content) { if let Some(body_cap) = BODY_CSS_RE.captures(style_content) {
let body_styles = &body_cap[1]; let body_styles = &body_cap[1];
if body_styles.to_lowercase().contains("background") { if body_styles.to_lowercase().contains("background") {
@@ -342,7 +305,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
} }
} }
// Extract from class rules that mention background
for class_cap in CLASS_CSS_RE.captures_iter(style_content) { for class_cap in CLASS_CSS_RE.captures_iter(style_content) {
let class_styles = &class_cap[1]; let class_styles = &class_cap[1];
if class_styles.to_lowercase().contains("background") { if class_styles.to_lowercase().contains("background") {
@@ -350,7 +312,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
} }
} }
// Extract from element-level rules (td{...}, table{...}, etc.)
for elem_cap in ELEM_CSS_RE.captures_iter(style_content) { for elem_cap in ELEM_CSS_RE.captures_iter(style_content) {
let tag = elem_cap[1].to_lowercase(); let tag = elem_cap[1].to_lowercase();
let elem_styles = &elem_cap[2]; let elem_styles = &elem_cap[2];
@@ -368,12 +329,9 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
} }
} }
/// Extract color values only from background/background-color properties in a CSS rule body.
fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec<BackgroundColor>) { fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec<BackgroundColor>) {
let lower = css.to_lowercase(); let lower = css.to_lowercase();
// Only extract colors from background/background-color property values,
// not from the entire CSS rule (which may contain color, border-color, etc.)
for bg_cap in BG_PROP_RE.captures_iter(&lower) { for bg_cap in BG_PROP_RE.captures_iter(&lower) {
let bg_value = bg_cap[1].trim(); let bg_value = bg_cap[1].trim();
@@ -425,8 +383,6 @@ fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec<Backgrou
} }
} }
// Check for named colors using word-boundary matching to avoid
// false positives (e.g. "white" matching inside "whitesmoke").
for (name, re) in NAMED_COLOR_REGEXES.iter() { for (name, re) in NAMED_COLOR_REGEXES.iter() {
if re.is_match(bg_value) { if re.is_match(bg_value) {
colors.push(BackgroundColor { colors.push(BackgroundColor {
@@ -462,7 +418,6 @@ fn extract_bgcolor_attrs(html: &str, colors: &mut Vec<BackgroundColor>) {
} }
} }
/// A color extracted from an inline style, with its alpha value.
struct ExtractedColor { struct ExtractedColor {
color: String, color: String,
alpha: f32, alpha: f32,
@@ -478,8 +433,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
.map(|m| m.as_str().to_lowercase()) .map(|m| m.as_str().to_lowercase())
.unwrap_or_default(); .unwrap_or_default();
// Only extract colors from background/background-color property values,
// not from the entire style string (which may contain border-color, color, etc.)
for bg_cap in BG_PROP_RE.captures_iter(&style_value) { for bg_cap in BG_PROP_RE.captures_iter(&style_value) {
let bg_value = bg_cap[1].trim(); let bg_value = bg_cap[1].trim();
@@ -512,8 +465,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
}); });
} }
// Check for named colors using word-boundary matching to avoid
// false positives (e.g. "white" matching inside "whitesmoke").
for (name, re) in NAMED_COLOR_REGEXES.iter() { for (name, re) in NAMED_COLOR_REGEXES.iter() {
if re.is_match(bg_value) { if re.is_match(bg_value) {
colors.push(ExtractedColor { colors.push(ExtractedColor {
@@ -529,8 +480,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
colors colors
} }
/// Parse the alpha channel from an 8-digit hex color (e.g. `#rrggbbaa`).
/// Returns 1.0 for 3/6-digit hex or if parsing fails.
fn parse_hex_alpha(color: &str) -> f32 { fn parse_hex_alpha(color: &str) -> f32 {
let c = color.trim().to_lowercase(); let c = color.trim().to_lowercase();
if let Some(hex) = c.strip_prefix('#') { if let Some(hex) = c.strip_prefix('#') {
@@ -545,7 +494,6 @@ fn parse_hex_alpha(color: &str) -> f32 {
1.0 1.0
} }
/// Parse an alpha string that may be a decimal (`0.5`) or percentage (`50%`).
fn parse_alpha_str(s: &str) -> f32 { fn parse_alpha_str(s: &str) -> f32 {
if let Some(pct) = s.strip_suffix('%') { if let Some(pct) = s.strip_suffix('%') {
pct.parse::<f32>().unwrap_or(100.0) / 100.0 pct.parse::<f32>().unwrap_or(100.0) / 100.0
@@ -577,7 +525,6 @@ fn normalize_color(color: &str) -> String {
} }
6 => c, 6 => c,
8 => { 8 => {
// 8-digit hex: strip alpha channel
format!("#{}", &hex[0..6]) format!("#{}", &hex[0..6])
} }
_ => c, _ => c,
@@ -595,7 +542,6 @@ fn calculate_luminance(color: &str) -> f64 {
fn parse_color(color: &str) -> (u8, u8, u8) { fn parse_color(color: &str) -> (u8, u8, u8) {
let c = color.trim().to_lowercase(); let c = color.trim().to_lowercase();
// Check named colors first
for (name, rgb) in NAMED_COLORS.iter() { for (name, rgb) in NAMED_COLORS.iter() {
if c == *name { if c == *name {
return *rgb; return *rgb;
@@ -629,7 +575,6 @@ fn parse_color(color: &str) -> (u8, u8, u8) {
); );
} }
// Unknown color - default to white (high luminance = light, safe fallback)
(255, 255, 255) (255, 255, 255)
} }
@@ -686,7 +631,6 @@ mod tests {
#[test] #[test]
fn test_luminance_grouping_mixed_light_colors() { fn test_luminance_grouping_mixed_light_colors() {
// Multiple light colors should still classify as Light
let html = r#"<html> let html = r#"<html>
<body style="background-color: #ffffff"> <body style="background-color: #ffffff">
<table style="background-color: #FAF9F5"> <table style="background-color: #FAF9F5">
@@ -700,29 +644,24 @@ mod tests {
#[test] #[test]
fn test_accent_td_not_treated_as_background() { fn test_accent_td_not_treated_as_background() {
// A single td with a dark accent color shouldn't classify the email as dark
let html = r##"<html><body><table><tr><td bgcolor="#2672ec">Click here</td></tr></table></body></html>"##; let html = r##"<html><body><table><tr><td bgcolor="#2672ec">Click here</td></tr></table></body></html>"##;
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
} }
#[test] #[test]
fn test_deduplicate_bgcolor_and_style() { fn test_deduplicate_bgcolor_and_style() {
// Same color from both bgcolor and style on same td should be deduplicated
let html = r##"<html><body><table><tr><td bgcolor="#2672ec" style="background-color:#2672ec">Click</td></tr></table></body></html>"##; let html = r##"<html><body><table><tr><td bgcolor="#2672ec" style="background-color:#2672ec">Click</td></tr></table></body></html>"##;
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
} }
#[test] #[test]
fn test_lone_white_wrapper_table_is_transparent() { fn test_lone_white_wrapper_table_is_transparent() {
// A wrapper table with background-color:white but no body background
// should be treated as transparent (boilerplate reset)
let html = r#"<html><body><table style="background-color:white"><tr><td>Content</td></tr></table></body></html>"#; let html = r#"<html><body><table style="background-color:white"><tr><td>Content</td></tr></table></body></html>"#;
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
} }
#[test] #[test]
fn test_named_color_white() { fn test_named_color_white() {
// Body with white background is intentional
let html = r#"<html><body style="background-color: white">Content</body></html>"#; let html = r#"<html><body style="background-color: white">Content</body></html>"#;
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
} }
@@ -760,8 +699,6 @@ mod tests {
#[test] #[test]
fn test_white_does_not_match_whitesmoke() { fn test_white_does_not_match_whitesmoke() {
// "whitesmoke" is a distinct named color (very light gray, RGB 245,245,245).
// It must NOT be misidentified as "white".
let html = r#"<html><body style="background-color: whitesmoke">Content</body></html>"#; let html = r#"<html><body style="background-color: whitesmoke">Content</body></html>"#;
let colors = extract_background_colors(html); let colors = extract_background_colors(html);
assert_eq!(colors.len(), 1); assert_eq!(colors.len(), 1);
@@ -810,7 +747,6 @@ mod tests {
#[test] #[test]
fn test_named_color_in_style_tag_exact_match() { fn test_named_color_in_style_tag_exact_match() {
// Ensure style-tag extraction also uses exact matching
let html = r#"<html><head><style>body { background-color: whitesmoke; }</style></head><body>Content</body></html>"#; let html = r#"<html><head><style>body { background-color: whitesmoke; }</style></head><body>Content</body></html>"#;
let colors = extract_background_colors(html); let colors = extract_background_colors(html);
assert!( assert!(
@@ -826,8 +762,6 @@ mod tests {
#[test] #[test]
fn test_rgba_half_alpha_reduced_weight() { fn test_rgba_half_alpha_reduced_weight() {
// rgba(0,0,0,0.5) on body should have weight 1.0 * 0.5 = 0.5
// compared to rgb(0,0,0) which would have weight 1.0
let html_rgba = let html_rgba =
r#"<html><body style="background-color: rgba(0,0,0,0.5)">Content</body></html>"#; r#"<html><body style="background-color: rgba(0,0,0,0.5)">Content</body></html>"#;
let html_rgb = r#"<html><body style="background-color: rgb(0,0,0)">Content</body></html>"#; let html_rgb = r#"<html><body style="background-color: rgb(0,0,0)">Content</body></html>"#;
@@ -851,7 +785,6 @@ mod tests {
#[test] #[test]
fn test_rgba_fully_transparent_ignored() { fn test_rgba_fully_transparent_ignored() {
// rgba(0,0,0,0) should be skipped entirely (alpha < 0.1)
let html = r#"<html><body style="background-color: rgba(0,0,0,0)">Content</body></html>"#; let html = r#"<html><body style="background-color: rgba(0,0,0,0)">Content</body></html>"#;
let colors = extract_background_colors(html); let colors = extract_background_colors(html);
assert!( assert!(
@@ -863,7 +796,6 @@ mod tests {
#[test] #[test]
fn test_rgba_full_alpha_same_as_rgb() { fn test_rgba_full_alpha_same_as_rgb() {
// rgba(255,255,255,1.0) should behave identically to rgb(255,255,255)
let html_rgba = let html_rgba =
r#"<html><body style="background-color: rgba(255,255,255,1.0)">Content</body></html>"#; r#"<html><body style="background-color: rgba(255,255,255,1.0)">Content</body></html>"#;
let html_rgb = let html_rgb =
@@ -885,7 +817,6 @@ mod tests {
#[test] #[test]
fn test_rgba_in_style_tag() { fn test_rgba_in_style_tag() {
// RGBA in a <style> block should also have reduced weight
let html = r#"<html><head><style>body { background-color: rgba(0,0,0,0.5); }</style></head><body>Content</body></html>"#; let html = r#"<html><head><style>body { background-color: rgba(0,0,0,0.5); }</style></head><body>Content</body></html>"#;
let colors = extract_background_colors(html); let colors = extract_background_colors(html);
assert_eq!(colors.len(), 1); assert_eq!(colors.len(), 1);
+6 -14
View File
@@ -2,13 +2,11 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use serde::Serialize; use serde::Serialize;
/// Structured JSON error envelope returned to clients.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct ErrorEnvelope { struct ErrorEnvelope {
error: ErrorBody, error: ErrorBody,
} }
/// Body of the error envelope.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct ErrorBody { struct ErrorBody {
code: String, code: String,
@@ -16,29 +14,25 @@ struct ErrorBody {
status: u16, status: u16,
} }
/// Application-level error type that converts into an Axum response
/// with a structured JSON error envelope.
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)] #[allow(dead_code)]
pub enum AppError { pub enum AppError {
/// Internal server error (500).
InternalError(String), InternalError(String),
/// Resource not found (404).
NotFound(String), NotFound(String),
/// Unauthorized access (401).
Unauthorized(String), Unauthorized(String),
/// Bad request (400).
BadRequest(String), BadRequest(String),
/// Authenticated but forbidden by policy (403).
Forbidden(String), Forbidden(String),
/// Service unavailable (503).
ServiceUnavailable(String), ServiceUnavailable(String),
/// Request payload is larger than the configured mail limit (413).
PayloadTooLarge(String), PayloadTooLarge(String),
} }
impl AppError { impl AppError {
/// Returns the HTTP status code for this error variant.
fn status_code(&self) -> StatusCode { fn status_code(&self) -> StatusCode {
match self { match self {
AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
@@ -51,7 +45,6 @@ impl AppError {
} }
} }
/// Returns the error code string for this error variant.
fn error_code(&self) -> &'static str { fn error_code(&self) -> &'static str {
match self { match self {
AppError::InternalError(_) => "INTERNAL_ERROR", AppError::InternalError(_) => "INTERNAL_ERROR",
@@ -64,7 +57,6 @@ impl AppError {
} }
} }
/// Returns the human-readable message for this error variant.
fn message(&self) -> &str { fn message(&self) -> &str {
match self { match self {
AppError::InternalError(msg) AppError::InternalError(msg)
+3 -80
View File
@@ -19,38 +19,24 @@ pub(crate) use super::connection::connect;
#[path = "mock.rs"] #[path = "mock.rs"]
pub mod mock; pub mod mock;
/// Abstraction over IMAP operations.
///
/// Every method receives explicit connection parameters so that the trait
/// keeps its API independent of connection management.
///
/// The `Send + Sync` bounds allow implementations to be shared across
/// Tokio tasks and stored in `Arc`.
#[allow(dead_code)] #[allow(dead_code)]
#[allow(clippy::double_must_use)] #[allow(clippy::double_must_use)]
#[async_trait] #[async_trait]
pub trait ImapClient: Send + Sync { pub trait ImapClient: Send + Sync {
/// Return the capabilities advertised by the authenticated IMAP server.
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError>; async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError>;
/// Drop reusable request-path connections for this account. The default
/// implementation is a no-op for test/alternate clients.
async fn reset_connections(&self, _creds: &ImapCredentials) -> Result<(), ImapError> { async fn reset_connections(&self, _creds: &ImapCredentials) -> Result<(), ImapError> {
Ok(()) Ok(())
} }
/// List all folders (mailboxes) on the server.
async fn list_folders(&self, creds: &ImapCredentials) -> Result<Vec<ImapFolder>, ImapError>; async fn list_folders(&self, creds: &ImapCredentials) -> Result<Vec<ImapFolder>, ImapError>;
/// Perform a lightweight `SELECT` on a folder to get its status
/// (UIDVALIDITY, EXISTS count, UIDNEXT) without fetching any messages.
async fn folder_status( async fn folder_status(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
folder: &str, folder: &str,
) -> Result<FolderStatus, ImapError>; ) -> Result<FolderStatus, ImapError>;
/// Fetch message headers (envelopes) for a range of UIDs in a folder.
async fn fetch_headers( async fn fetch_headers(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -58,7 +44,6 @@ pub trait ImapClient: Send + Sync {
uid_range: &str, uid_range: &str,
) -> Result<Vec<ImapMessageHeader>, ImapError>; ) -> Result<Vec<ImapMessageHeader>, ImapError>;
/// Fetch the full body of a single message by UID.
async fn fetch_body( async fn fetch_body(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -66,7 +51,6 @@ pub trait ImapClient: Send + Sync {
uid: u32, uid: u32,
) -> Result<ImapMessageBody, ImapError>; ) -> Result<ImapMessageBody, ImapError>;
/// Fetch the complete RFC822 message bytes for source/download operations.
async fn fetch_raw_message( async fn fetch_raw_message(
&self, &self,
_creds: &ImapCredentials, _creds: &ImapCredentials,
@@ -79,7 +63,6 @@ pub trait ImapClient: Send + Sync {
}) })
} }
/// Add flags to a message (IMAP +FLAGS).
async fn add_flags( async fn add_flags(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -88,7 +71,6 @@ pub trait ImapClient: Send + Sync {
flags: &[&str], flags: &[&str],
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Remove flags from a message (IMAP -FLAGS).
async fn remove_flags( async fn remove_flags(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -97,7 +79,6 @@ pub trait ImapClient: Send + Sync {
flags: &[&str], flags: &[&str],
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Set (replace) the flags on a message.
async fn set_flags( async fn set_flags(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -106,7 +87,6 @@ pub trait ImapClient: Send + Sync {
flags: &[&str], flags: &[&str],
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Move a message from one folder to another.
async fn move_message( async fn move_message(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -115,7 +95,6 @@ pub trait ImapClient: Send + Sync {
to_folder: &str, to_folder: &str,
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Copy a message to another folder without removing the source.
async fn copy_message( async fn copy_message(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -124,13 +103,10 @@ pub trait ImapClient: Send + Sync {
to_folder: &str, to_folder: &str,
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Mark all messages in a folder as read.
async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>; async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
/// Permanently remove every message from a folder.
async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>; async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
/// Permanently remove a message that has the `\Deleted` flag.
async fn expunge_message( async fn expunge_message(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -138,7 +114,6 @@ pub trait ImapClient: Send + Sync {
uid: u32, uid: u32,
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Append a raw RFC822 message to a folder (e.g. saving sent mail to "Sent").
async fn append_message( async fn append_message(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -147,11 +122,6 @@ pub trait ImapClient: Send + Sync {
flags: &[&str], flags: &[&str],
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Append a message and resolve the server-assigned UID without guessing.
///
/// `identity_header` and `identity_value` must uniquely identify this
/// application-owned snapshot. Implementations only accept a UID at or
/// above the mailbox UIDNEXT observed before APPEND.
async fn append_message_resolved_uid( async fn append_message_resolved_uid(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -162,14 +132,12 @@ pub trait ImapClient: Send + Sync {
identity_value: &str, identity_value: &str,
) -> Result<(u32, u32), ImapError>; ) -> Result<(u32, u32), ImapError>;
/// Create a new folder (mailbox) and subscribe to it.
async fn create_folder( async fn create_folder(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
folder_name: &str, folder_name: &str,
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Rename an existing folder.
async fn rename_folder( async fn rename_folder(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -177,14 +145,12 @@ pub trait ImapClient: Send + Sync {
to: &str, to: &str,
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Permanently delete a folder (mailbox).
async fn delete_folder( async fn delete_folder(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
folder_name: &str, folder_name: &str,
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Subscribe to or unsubscribe from a folder.
async fn subscribe_folder( async fn subscribe_folder(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -192,24 +158,18 @@ pub trait ImapClient: Send + Sync {
subscribe: bool, subscribe: bool,
) -> Result<(), ImapError>; ) -> Result<(), ImapError>;
/// Fetch only UIDs and FLAGS for all messages in a folder.
/// Used for periodic reconciliation to detect flag changes and deletions.
async fn fetch_uids_and_flags( async fn fetch_uids_and_flags(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
folder: &str, folder: &str,
) -> Result<Vec<(u32, Vec<String>)>, ImapError>; ) -> Result<Vec<(u32, Vec<String>)>, ImapError>;
/// Lightweight STATUS command to get folder metadata without SELECT.
/// Returns UIDVALIDITY, EXISTS, UIDNEXT, UNSEEN, and HIGHESTMODSEQ.
async fn folder_status_extended( async fn folder_status_extended(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
folder: &str, folder: &str,
) -> Result<FolderStatusExtended, ImapError>; ) -> Result<FolderStatusExtended, ImapError>;
/// Fetch only messages whose flags changed since `since_modseq` using CONDSTORE.
/// Returns (vec of (uid, flags), new_highest_modseq).
async fn fetch_changed_flags( async fn fetch_changed_flags(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -217,19 +177,14 @@ pub trait ImapClient: Send + Sync {
since_modseq: u64, since_modseq: u64,
) -> Result<(Vec<(u32, Vec<String>)>, u64), ImapError>; ) -> Result<(Vec<(u32, Vec<String>)>, u64), ImapError>;
/// Fetch mailbox quota via IMAP GETQUOTAROOT.
/// Returns `None` if the server doesn't support quotas.
async fn get_quota(&self, creds: &ImapCredentials) -> Result<Option<MailboxQuota>, ImapError>; async fn get_quota(&self, creds: &ImapCredentials) -> Result<Option<MailboxQuota>, ImapError>;
/// Fetch the total size of all messages in a folder via UID FETCH 1:* (RFC822.SIZE).
async fn fetch_folder_size( async fn fetch_folder_size(
&self, &self,
creds: &ImapCredentials, creds: &ImapCredentials,
folder: &str, folder: &str,
) -> Result<u64, ImapError>; ) -> Result<u64, ImapError>;
/// Return whether the selected mailbox allows creating persistent custom keywords.
/// An absent PERMANENTFLAGS response is treated according to RFC 3501 as permissive.
async fn folder_keyword_support( async fn folder_keyword_support(
&self, &self,
_creds: &ImapCredentials, _creds: &ImapCredentials,
@@ -239,21 +194,11 @@ pub trait ImapClient: Send + Sync {
} }
} }
// ---------------------------------------------------------------------------
// Real implementation backed by async-imap
// ---------------------------------------------------------------------------
/// Production IMAP client that uses `async-imap` and `mail-parser`.
///
/// Read operations reuse bounded, exclusively leased authenticated sessions.
/// Writes retain dedicated connections for now.
#[derive(Default)] #[derive(Default)]
pub struct RealImapClient { pub struct RealImapClient {
pool: super::pool::ConnectionPool, pool: super::pool::ConnectionPool,
} }
// ---- Trait implementation -------------------------------------------------
#[async_trait] #[async_trait]
impl ImapClient for RealImapClient { impl ImapClient for RealImapClient {
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError> { async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError> {
@@ -363,9 +308,6 @@ impl ImapClient for RealImapClient {
})?; })?;
let fetches = { let fetches = {
// Fetch ENVELOPE, FLAGS, BODYSTRUCTURE, RFC822.SIZE, and threading headers.
// We only fetch Message-ID, In-Reply-To, and References (a few bytes per message)
// rather than full raw headers, to keep bulk syncs lightweight.
let mut fetch_stream = session let mut fetch_stream = session
.uid_fetch( .uid_fetch(
uid_range, uid_range,
@@ -391,7 +333,6 @@ impl ImapClient for RealImapClient {
None => continue, None => continue,
}; };
// Parse threading headers from the small HEADER.FIELDS response.
let raw_header_bytes = fetch.header(); let raw_header_bytes = fetch.header();
let parsed_threading = raw_header_bytes let parsed_threading = raw_header_bytes
.and_then(|raw| mail_parser::MessageParser::default().parse(raw)); .and_then(|raw| mail_parser::MessageParser::default().parse(raw));
@@ -429,9 +370,6 @@ impl ImapClient for RealImapClient {
(subject, from, to, cc, date) (subject, from, to, cc, date)
} else { } else {
// No envelope — we can't fill subject/from/to/date from the
// small threading-only header fetch, so leave them empty.
// They'll be populated when the user opens the message body.
tracing::warn!( tracing::warn!(
uid = uid, uid = uid,
folder = %folder, folder = %folder,
@@ -440,7 +378,6 @@ impl ImapClient for RealImapClient {
(None, vec![], vec![], vec![], None) (None, vec![], vec![], vec![], None)
}; };
// Extract threading headers from the small HEADER.FIELDS response.
let message_id = parsed_threading let message_id = parsed_threading
.as_ref() .as_ref()
.and_then(|p| p.message_id().map(|s| format!("<{s}>"))); .and_then(|p| p.message_id().map(|s| format!("<{s}>")));
@@ -460,7 +397,6 @@ impl ImapClient for RealImapClient {
.or_else(|| val.as_text().map(|s| format!("<{s}>"))) .or_else(|| val.as_text().map(|s| format!("<{s}>")))
}); });
// Detect Outlook/Exchange reaction emails from headers.
let reaction = raw_header_bytes.and_then(|raw| { let reaction = raw_header_bytes.and_then(|raw| {
let header_str = std::str::from_utf8(raw).ok()?; let header_str = std::str::from_utf8(raw).ok()?;
let lower = header_str.to_lowercase(); let lower = header_str.to_lowercase();
@@ -587,7 +523,6 @@ impl ImapClient for RealImapClient {
let mut attachments = Vec::new(); let mut attachments = Vec::new();
// Collect explicit attachments.
for attachment in parsed.attachments() { for attachment in parsed.attachments() {
let filename: Option<String> = attachment.attachment_name().map(|s| s.to_string()); let filename: Option<String> = attachment.attachment_name().map(|s| s.to_string());
let content_type: String = attachment.content_type().map_or_else( let content_type: String = attachment.content_type().map_or_else(
@@ -614,13 +549,11 @@ impl ImapClient for RealImapClient {
}); });
} }
// Also collect inline parts with Content-ID (e.g. embedded images
// referenced via cid: URLs in the HTML body).
for part in parsed.parts.iter() { for part in parsed.parts.iter() {
if part.content_id().is_none() { if part.content_id().is_none() {
continue; continue;
} }
// Skip if this is a text/html or text/plain body part.
let is_text = part.content_type().is_some_and(|ct| ct.ctype() == "text"); let is_text = part.content_type().is_some_and(|ct| ct.ctype() == "text");
if is_text { if is_text {
continue; continue;
@@ -630,7 +563,7 @@ impl ImapClient for RealImapClient {
.unwrap() .unwrap()
.trim_matches(|c| c == '<' || c == '>') .trim_matches(|c| c == '<' || c == '>')
.to_string(); .to_string();
// Skip if we already captured this part via attachments().
if attachments if attachments
.iter() .iter()
.any(|a| a.content_id.as_deref() == Some(&cid)) .any(|a| a.content_id.as_deref() == Some(&cid))
@@ -658,7 +591,6 @@ impl ImapClient for RealImapClient {
}); });
} }
// Extract raw headers from the RFC 822 message.
let raw_str = String::from_utf8_lossy(raw); let raw_str = String::from_utf8_lossy(raw);
let raw_headers = raw_str let raw_headers = raw_str
.split_once("\r\n\r\n") .split_once("\r\n\r\n")
@@ -780,7 +712,6 @@ impl ImapClient for RealImapClient {
.await .await
.map_err(map_imap_error)?; .map_err(map_imap_error)?;
// Consume the stream to completion so the command finishes.
while let Some(result) = store_stream.next().await { while let Some(result) = store_stream.next().await {
result.map_err(map_imap_error)?; result.map_err(map_imap_error)?;
} }
@@ -803,12 +734,9 @@ impl ImapClient for RealImapClient {
let uid_str = uid.to_string(); let uid_str = uid.to_string();
// Try UID MOVE first; fall back to COPY + DELETE + EXPUNGE if the
// server does not support the MOVE extension.
match session.uid_mv(&uid_str, to_folder).await { match session.uid_mv(&uid_str, to_folder).await {
Ok(()) => {} Ok(()) => {}
Err(async_imap::error::Error::No(_) | async_imap::error::Error::Bad(_)) => { Err(async_imap::error::Error::No(_) | async_imap::error::Error::Bad(_)) => {
// Fallback: COPY, then flag \Deleted, then EXPUNGE.
session session
.uid_copy(&uid_str, to_folder) .uid_copy(&uid_str, to_folder)
.await .await
@@ -907,7 +835,6 @@ impl ImapClient for RealImapClient {
let uid_str = uid.to_string(); let uid_str = uid.to_string();
// Mark the message as \Deleted.
{ {
let mut store_stream = session let mut store_stream = session
.uid_store(&uid_str, "+FLAGS (\\Deleted)") .uid_store(&uid_str, "+FLAGS (\\Deleted)")
@@ -918,7 +845,6 @@ impl ImapClient for RealImapClient {
} }
} }
// Try UID EXPUNGE for precision; fall back to EXPUNGE.
let uid_expunge_ok = { let uid_expunge_ok = {
match session.uid_expunge(&uid_str).await { match session.uid_expunge(&uid_str).await {
Ok(stream) => { Ok(stream) => {
@@ -1214,8 +1140,7 @@ impl ImapClient for RealImapClient {
.run_command("GETQUOTAROOT INBOX") .run_command("GETQUOTAROOT INBOX")
.await .await
.map_err(map_imap_error)?; .map_err(map_imap_error)?;
// Matching tagged OK/NO/BAD completes the command. Transport failures
// and incomplete replies still discard the session.
let (quota, reusable) = tokio::time::timeout(std::time::Duration::from_secs(15), async { let (quota, reusable) = tokio::time::timeout(std::time::Duration::from_secs(15), async {
let mut quota = None; let mut quota = None;
while let Some(response) = session.read_response().await { while let Some(response) = session.read_response().await {
@@ -1277,8 +1202,6 @@ impl ImapClient for RealImapClient {
let mut total: u64 = 0; let mut total: u64 = 0;
// Require a tagged completion: async-imap's FETCH stream hides tagged
// NO/BAD and can end at EOF, which is unsafe when returning a pooled session.
let fetch_result = tokio::time::timeout(std::time::Duration::from_secs(60), async { let fetch_result = tokio::time::timeout(std::time::Duration::from_secs(60), async {
use async_imap::imap_proto::{Response, Status, types::AttributeValue}; use async_imap::imap_proto::{Response, Status, types::AttributeValue};
let req_id = session let req_id = session
+1 -20
View File
@@ -1,7 +1,6 @@
use super::error::ImapError; use super::error::ImapError;
use super::types::ImapCredentials; use super::types::ImapCredentials;
// Counts connection attempts by credential identity for isolated regression tests.
#[cfg(test)] #[cfg(test)]
static CONNECTION_COUNTS: std::sync::LazyLock< static CONNECTION_COUNTS: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<[u8; 32], usize>>, std::sync::Mutex<std::collections::HashMap<[u8; 32], usize>>,
@@ -16,14 +15,6 @@ pub(super) fn connection_count(creds: &ImapCredentials) -> usize {
.unwrap_or(&0) .unwrap_or(&0)
} }
// ---- Connection helper ----------------------------------------------------
/// Establish an authenticated IMAP session.
///
/// Returns a `Session` over a TLS stream (when `creds.tls` is true) or a
/// plain TCP stream. Because the two stream types are different concrete
/// types we use an enum wrapper that implements the traits `async-imap`
/// requires (`tokio::io::AsyncRead + AsyncWrite + Unpin + Debug`).
pub(crate) async fn connect( pub(crate) async fn connect(
creds: &ImapCredentials, creds: &ImapCredentials,
) -> Result<async_imap::Session<ImapStream>, ImapError> { ) -> Result<async_imap::Session<ImapStream>, ImapError> {
@@ -36,7 +27,7 @@ pub(crate) async fn connect(
.or_default() += 1; .or_default() += 1;
} }
let connect_future = tokio::net::TcpStream::connect((creds.host.as_str(), creds.port)); let connect_future = tokio::net::TcpStream::connect((creds.host.as_str(), creds.port));
// 10 second timeout for the initial TCP connection
let tcp = tokio::time::timeout(std::time::Duration::from_secs(10), connect_future) let tcp = tokio::time::timeout(std::time::Duration::from_secs(10), connect_future)
.await .await
.map_err(|_| ImapError::ConnectionFailed("connection timed out".to_string()))? .map_err(|_| ImapError::ConnectionFailed("connection timed out".to_string()))?
@@ -68,10 +59,6 @@ pub(crate) async fn connect(
} }
} }
/// Enable RFC 6855 UTF8=ACCEPT once per freshly authenticated session when
/// the server explicitly advertises both ENABLE and UTF8=ACCEPT. A tagged
/// NO/BAD is treated as a capability mismatch and leaves the session usable
/// in legacy mode; transport loss still invalidates the connection.
async fn maybe_enable_utf8_accept( async fn maybe_enable_utf8_accept(
session: &mut async_imap::Session<ImapStream>, session: &mut async_imap::Session<ImapStream>,
) -> Result<(), ImapError> { ) -> Result<(), ImapError> {
@@ -119,7 +106,6 @@ async fn maybe_enable_utf8_accept(
)) ))
} }
/// Classify an `async_imap::error::Error` that occurred during LOGIN.
pub(crate) fn classify_login_error(err: async_imap::error::Error) -> ImapError { pub(crate) fn classify_login_error(err: async_imap::error::Error) -> ImapError {
match err { match err {
async_imap::error::Error::No(_) => ImapError::AuthenticationFailed, async_imap::error::Error::No(_) => ImapError::AuthenticationFailed,
@@ -131,7 +117,6 @@ pub(crate) fn classify_login_error(err: async_imap::error::Error) -> ImapError {
} }
} }
/// Map a generic `async_imap` error to our `ImapError`.
pub(crate) fn map_imap_error(err: async_imap::error::Error) -> ImapError { pub(crate) fn map_imap_error(err: async_imap::error::Error) -> ImapError {
match err { match err {
async_imap::error::Error::No(msg) => ImapError::ProtocolError(format!("NO: {msg}")), async_imap::error::Error::No(msg) => ImapError::ProtocolError(format!("NO: {msg}")),
@@ -143,10 +128,6 @@ pub(crate) fn map_imap_error(err: async_imap::error::Error) -> ImapError {
} }
} }
// ---- Stream enum ----------------------------------------------------------
/// A wrapper enum so that `Session` can be generic over a single type
/// regardless of whether TLS is used.
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum ImapStream { pub(crate) enum ImapStream {
Tls(async_native_tls::TlsStream<tokio::net::TcpStream>), Tls(async_native_tls::TlsStream<tokio::net::TcpStream>),
+4 -6
View File
@@ -1,17 +1,15 @@
use std::fmt; use std::fmt;
/// Errors that can occur during IMAP operations.
#[derive(Debug)] #[derive(Debug)]
pub enum ImapError { pub enum ImapError {
/// Could not connect to the IMAP server.
ConnectionFailed(String), ConnectionFailed(String),
/// The server rejected our credentials.
AuthenticationFailed, AuthenticationFailed,
/// The requested folder does not exist.
FolderNotFound(String), FolderNotFound(String),
/// The requested message UID was not found in the given folder.
MessageNotFound { uid: u32, folder: String }, MessageNotFound { uid: u32, folder: String },
/// A low-level IMAP protocol error.
ProtocolError(String), ProtocolError(String),
} }
+10 -47
View File
@@ -1,11 +1,6 @@
use super::*; use super::*;
use std::sync::Mutex; use std::sync::Mutex;
/// A mock IMAP client that returns pre-loaded data.
///
/// Uses interior mutability (`Mutex`) so it can be shared behind `&self`.
/// Build it up with the `.with_*()` builder methods, then pass it wherever
/// an `&dyn ImapClient` is needed.
pub struct MockImapClient { pub struct MockImapClient {
folders: Mutex<Vec<ImapFolder>>, folders: Mutex<Vec<ImapFolder>>,
headers: Mutex<Vec<ImapMessageHeader>>, headers: Mutex<Vec<ImapMessageHeader>>,
@@ -15,12 +10,11 @@ pub struct MockImapClient {
should_fail: Mutex<Option<ImapError>>, should_fail: Mutex<Option<ImapError>>,
pub appended: Mutex<Vec<(String, Vec<u8>)>>, pub appended: Mutex<Vec<(String, Vec<u8>)>>,
pub header_requests: Mutex<Vec<(String, String)>>, pub header_requests: Mutex<Vec<(String, String)>>,
/// One-shot gate after taking a header snapshot, before returning it.
pub header_fetch_gate: Option<(tokio::sync::Notify, tokio::sync::Notify)>, pub header_fetch_gate: Option<(tokio::sync::Notify, tokio::sync::Notify)>,
} }
impl MockImapClient { impl MockImapClient {
/// Create a new empty mock.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
folders: Mutex::new(Vec::new()), folders: Mutex::new(Vec::new()),
@@ -35,47 +29,39 @@ impl MockImapClient {
} }
} }
/// Pre-load folders that `list_folders` will return.
pub fn with_folders(self, folders: Vec<ImapFolder>) -> Self { pub fn with_folders(self, folders: Vec<ImapFolder>) -> Self {
*self.folders.lock().unwrap() = folders; *self.folders.lock().unwrap() = folders;
self self
} }
/// Pre-load message headers that `fetch_headers` will return.
pub fn with_headers(self, headers: Vec<ImapMessageHeader>) -> Self { pub fn with_headers(self, headers: Vec<ImapMessageHeader>) -> Self {
*self.headers.lock().unwrap() = headers; *self.headers.lock().unwrap() = headers;
self self
} }
/// Pre-load a folder status that `folder_status` will return.
#[allow(dead_code)] #[allow(dead_code)]
pub fn with_folder_status(self, status: FolderStatus) -> Self { pub fn with_folder_status(self, status: FolderStatus) -> Self {
*self.folder_status.lock().unwrap() = Some(status); *self.folder_status.lock().unwrap() = Some(status);
self self
} }
/// Pre-load an extended folder status that `folder_status_extended` will return.
#[allow(dead_code)] #[allow(dead_code)]
pub fn with_folder_status_extended(self, status: FolderStatusExtended) -> Self { pub fn with_folder_status_extended(self, status: FolderStatusExtended) -> Self {
*self.folder_status_ext.lock().unwrap() = Some(status); *self.folder_status_ext.lock().unwrap() = Some(status);
self self
} }
/// Pre-load message bodies that `fetch_body` will match against by UID.
pub fn with_bodies(self, bodies: Vec<ImapMessageBody>) -> Self { pub fn with_bodies(self, bodies: Vec<ImapMessageBody>) -> Self {
*self.bodies.lock().unwrap() = bodies; *self.bodies.lock().unwrap() = bodies;
self self
} }
/// Make every subsequent call return this error.
pub fn with_error(self, error: ImapError) -> Self { pub fn with_error(self, error: ImapError) -> Self {
*self.should_fail.lock().unwrap() = Some(error); *self.should_fail.lock().unwrap() = Some(error);
self self
} }
} }
/// Helper to clone an `ImapError` for the mock (the real errors are not
/// `Clone`, so we reconstruct them).
fn clone_error(err: &ImapError) -> ImapError { fn clone_error(err: &ImapError) -> ImapError {
match err { match err {
ImapError::ConnectionFailed(msg) => ImapError::ConnectionFailed(msg.clone()), ImapError::ConnectionFailed(msg) => ImapError::ConnectionFailed(msg.clone()),
@@ -127,7 +113,7 @@ impl ImapClient for MockImapClient {
return Ok(s.clone()); return Ok(s.clone());
} }
} }
// Derive from headers (separate lock scope).
let headers = self.headers.lock().unwrap(); let headers = self.headers.lock().unwrap();
let exists = headers.len() as u32; let exists = headers.len() as u32;
let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1; let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1;
@@ -255,11 +241,7 @@ impl ImapClient for MockImapClient {
Ok(()) Ok(())
} }
async fn empty_folder( async fn empty_folder(&self, _creds: &ImapCredentials, _folder: &str) -> Result<(), ImapError> {
&self,
_creds: &ImapCredentials,
_folder: &str,
) -> Result<(), ImapError> {
if let Some(ref err) = *self.should_fail.lock().unwrap() { if let Some(ref err) = *self.should_fail.lock().unwrap() {
return Err(clone_error(err)); return Err(clone_error(err));
} }
@@ -390,7 +372,7 @@ impl ImapClient for MockImapClient {
return Ok(s.clone()); return Ok(s.clone());
} }
} }
// Derive from headers.
let headers = self.headers.lock().unwrap(); let headers = self.headers.lock().unwrap();
let exists = headers.len() as u32; let exists = headers.len() as u32;
let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1; let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1;
@@ -412,7 +394,7 @@ impl ImapClient for MockImapClient {
if let Some(ref err) = *self.should_fail.lock().unwrap() { if let Some(ref err) = *self.should_fail.lock().unwrap() {
return Err(clone_error(err)); return Err(clone_error(err));
} }
// In mock, return all headers as "changed" with modseq 0.
let headers = self.headers.lock().unwrap(); let headers = self.headers.lock().unwrap();
let items: Vec<(u32, Vec<String>)> = let items: Vec<(u32, Vec<String>)> =
headers.iter().map(|h| (h.uid, h.flags.clone())).collect(); headers.iter().map(|h| (h.uid, h.flags.clone())).collect();
@@ -438,15 +420,10 @@ impl ImapClient for MockImapClient {
} }
} }
// -----------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// Convenience helper to build dummy credentials for tests.
fn test_creds() -> ImapCredentials { fn test_creds() -> ImapCredentials {
ImapCredentials { ImapCredentials {
host: "imap.example.com".to_string(), host: "imap.example.com".to_string(),
@@ -636,7 +613,7 @@ mod tests {
let creds = test_creds(); let creds = test_creds();
let err = client.list_folders(&creds).await.unwrap_err(); let err = client.list_folders(&creds).await.unwrap_err();
// With a fake host the connection should fail.
assert!( assert!(
matches!(err, ImapError::ConnectionFailed(_)), matches!(err, ImapError::ConnectionFailed(_)),
"Expected ConnectionFailed, got: {err}" "Expected ConnectionFailed, got: {err}"
@@ -760,19 +737,6 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
} }
// -------------------------------------------------------------------
// Integration tests (run manually against a real IMAP server)
// -------------------------------------------------------------------
//
// cargo test real_imap -- --ignored
//
// Required env vars:
// TEST_IMAP_HOST (e.g. "imap.gmail.com")
// TEST_IMAP_PORT (e.g. "993")
// TEST_IMAP_EMAIL (e.g. "you@gmail.com")
// TEST_IMAP_PASSWORD (e.g. "app-password")
// TEST_IMAP_TLS (e.g. "true")
fn real_creds() -> Option<ImapCredentials> { fn real_creds() -> Option<ImapCredentials> {
let host = std::env::var("TEST_IMAP_HOST").ok()?; let host = std::env::var("TEST_IMAP_HOST").ok()?;
let port: u16 = std::env::var("TEST_IMAP_PORT").ok()?.parse().ok()?; let port: u16 = std::env::var("TEST_IMAP_PORT").ok()?.parse().ok()?;
@@ -793,7 +757,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[ignore] // Run manually: cargo test real_imap_list_folders -- --ignored #[ignore]
async fn real_imap_list_folders() { async fn real_imap_list_folders() {
let creds = real_creds().expect("TEST_IMAP_* env vars required"); let creds = real_creds().expect("TEST_IMAP_* env vars required");
let client = RealImapClient::default(); let client = RealImapClient::default();
@@ -807,24 +771,23 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[ignore] // Run manually: cargo test real_imap_fetch_headers -- --ignored #[ignore]
async fn real_imap_fetch_headers() { async fn real_imap_fetch_headers() {
let creds = real_creds().expect("TEST_IMAP_* env vars required"); let creds = real_creds().expect("TEST_IMAP_* env vars required");
let client = RealImapClient::default(); let client = RealImapClient::default();
let headers = client.fetch_headers(&creds, "INBOX", "1:5").await.unwrap(); let headers = client.fetch_headers(&creds, "INBOX", "1:5").await.unwrap();
// The mailbox might be empty, so we just check it doesn't error.
for h in &headers { for h in &headers {
assert!(h.uid > 0); assert!(h.uid > 0);
} }
} }
#[tokio::test] #[tokio::test]
#[ignore] // Run manually: cargo test real_imap_fetch_body -- --ignored #[ignore]
async fn real_imap_fetch_body() { async fn real_imap_fetch_body() {
let creds = real_creds().expect("TEST_IMAP_* env vars required"); let creds = real_creds().expect("TEST_IMAP_* env vars required");
let client = RealImapClient::default(); let client = RealImapClient::default();
// First fetch headers to find a UID.
let headers = client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap(); let headers = client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap();
if let Some(h) = headers.first() { if let Some(h) = headers.first() {
let body = client.fetch_body(&creds, "INBOX", h.uid).await.unwrap(); let body = client.fetch_body(&creds, "INBOX", h.uid).await.unwrap();
+7 -25
View File
@@ -1,7 +1,5 @@
use super::types::EmailAddress; use super::types::EmailAddress;
// ---- Helper: convert NameAttribute to string ------------------------------
pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'_>) -> String { pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'_>) -> String {
use async_imap::types::NameAttribute; use async_imap::types::NameAttribute;
match attr { match attr {
@@ -21,8 +19,6 @@ pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'
} }
} }
// ---- Helper: convert Flag to string ---------------------------------------
pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String { pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String {
use async_imap::types::Flag; use async_imap::types::Flag;
match flag { match flag {
@@ -37,11 +33,7 @@ pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String {
} }
} }
// ---- Helper: decode RFC 2047 encoded words --------------------------------
/// Decode RFC 2047 encoded-word sequences like `=?utf-8?b?SGVsbG8=?=`.
pub(crate) fn decode_rfc2047(input: &str) -> String { pub(crate) fn decode_rfc2047(input: &str) -> String {
// Quick check: if there's no encoded-word marker, return as-is.
if !input.contains("=?") { if !input.contains("=?") {
return input.to_string(); return input.to_string();
} }
@@ -50,11 +42,10 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
let mut remaining = input; let mut remaining = input;
while let Some(start) = remaining.find("=?") { while let Some(start) = remaining.find("=?") {
// Add any text before the encoded word.
result.push_str(&remaining[..start]); result.push_str(&remaining[..start]);
let after_start = &remaining[start + 2..]; let after_start = &remaining[start + 2..];
// Find charset?encoding?text?= pattern.
if let Some(q1) = after_start.find('?') { if let Some(q1) = after_start.find('?') {
let charset = &after_start[..q1]; let charset = &after_start[..q1];
let after_charset = &after_start[q1 + 1..]; let after_charset = &after_start[q1 + 1..];
@@ -77,15 +68,13 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
let text = decode_charset(charset, &bytes); let text = decode_charset(charset, &bytes);
result.push_str(&text); result.push_str(&text);
} else { } else {
// Failed to decode — keep the original encoded word.
let full_len = 2 + q1 + 1 + q2 + 1 + end + 2; let full_len = 2 + q1 + 1 + q2 + 1 + end + 2;
result.push_str(&remaining[start..start + full_len]); result.push_str(&remaining[start..start + full_len]);
} }
// Skip past the encoded word.
let skip = q1 + 1 + q2 + 1 + end + 2; let skip = q1 + 1 + q2 + 1 + end + 2;
remaining = &after_start[skip..]; remaining = &after_start[skip..];
// Strip whitespace between consecutive encoded words (RFC 2047 §6.2).
if remaining.starts_with("=?") || remaining.trim_start().starts_with("=?") { if remaining.starts_with("=?") || remaining.trim_start().starts_with("=?") {
remaining = remaining.trim_start(); remaining = remaining.trim_start();
} }
@@ -94,7 +83,6 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
} }
} }
// Malformed encoded word — keep the `=?` and move past it.
result.push_str("=?"); result.push_str("=?");
remaining = after_start; remaining = after_start;
} }
@@ -103,7 +91,6 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
result result
} }
/// Decode quoted-printable as used in RFC 2047 headers (underscores = spaces).
pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> { pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> {
let mut bytes = Vec::with_capacity(input.len()); let mut bytes = Vec::with_capacity(input.len());
let mut chars = input.bytes(); let mut chars = input.bytes();
@@ -124,20 +111,19 @@ pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> {
Some(bytes) Some(bytes)
} }
/// Best-effort charset decoding. UTF-8 and Latin-1 are the most common.
pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String { pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String {
match charset.to_ascii_lowercase().as_str() { match charset.to_ascii_lowercase().as_str() {
"utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(), "utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(),
"iso-8859-1" | "latin1" | "latin-1" => bytes.iter().map(|&b| b as char).collect(), "iso-8859-1" | "latin1" | "latin-1" => bytes.iter().map(|&b| b as char).collect(),
// For other charsets, try UTF-8 first, then fall back to Latin-1.
_ => String::from_utf8(bytes.to_vec()) _ => String::from_utf8(bytes.to_vec())
.unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect()), .unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect()),
} }
} }
// ---- Helper: convert imap_proto::Address to EmailAddress ------------------ pub(crate) fn imap_address_to_email(
addr: &async_imap::imap_proto::types::Address<'_>,
pub(crate) fn imap_address_to_email(addr: &async_imap::imap_proto::types::Address<'_>) -> EmailAddress { ) -> EmailAddress {
let name = addr let name = addr
.name .name
.as_ref() .as_ref()
@@ -164,24 +150,20 @@ pub(crate) fn imap_address_to_email(addr: &async_imap::imap_proto::types::Addres
EmailAddress { name, address } EmailAddress { name, address }
} }
// ---- Helper: detect attachments from BODYSTRUCTURE -----------------------
pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<'_>) -> bool { pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<'_>) -> bool {
use async_imap::imap_proto::types::BodyStructure; use async_imap::imap_proto::types::BodyStructure;
match bs { match bs {
BodyStructure::Basic { common, .. } => { BodyStructure::Basic { common, .. } => {
// Check for explicit "attachment" disposition.
if let Some(ref disp) = common.disposition if let Some(ref disp) = common.disposition
&& disp.ty.eq_ignore_ascii_case("attachment") && disp.ty.eq_ignore_ascii_case("attachment")
{ {
return true; return true;
} }
// Non-text basic parts (image, application, audio, video) are likely attachments.
let ty = common.ty.ty.to_ascii_lowercase(); let ty = common.ty.ty.to_ascii_lowercase();
matches!(ty.as_str(), "application" | "image" | "audio" | "video") matches!(ty.as_str(), "application" | "image" | "audio" | "video")
} }
BodyStructure::Text { common, .. } => { BodyStructure::Text { common, .. } => {
// Text parts with "attachment" disposition count.
if let Some(ref disp) = common.disposition { if let Some(ref disp) = common.disposition {
return disp.ty.eq_ignore_ascii_case("attachment"); return disp.ty.eq_ignore_ascii_case("attachment");
} }
+5 -15
View File
@@ -1,4 +1,3 @@
//! Request-path sessions only; IDLE owns a separate connection.
use super::{ use super::{
connection::{ImapStream, connect}, connection::{ImapStream, connect},
error::ImapError, error::ImapError,
@@ -19,7 +18,6 @@ use tokio::{
time::Instant, time::Instant,
}; };
/// Mark IMAP operations in this future as background work.
pub(crate) async fn background<T>(work: impl std::future::Future<Output = T>) -> T { pub(crate) async fn background<T>(work: impl std::future::Future<Output = T>) -> T {
BACKGROUND.scope((), work).await BACKGROUND.scope((), work).await
} }
@@ -47,7 +45,6 @@ struct UserPool {
idle: Mutex<Vec<(Instant, Session)>>, idle: Mutex<Vec<(Instant, Session)>>,
} }
// Length-prefix fields to avoid ambiguous identities. Never retain plaintext passwords.
pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] { pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] {
let mut hash = Sha256::new(); let mut hash = Sha256::new();
for field in [ for field in [
@@ -64,8 +61,6 @@ pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] {
hash.finalize().into() hash.finalize().into()
} }
// async-imap 0.10's noop parser accepts EOF without a tagged completion.
// Require the matching tagged OK ourselves so dead sockets cannot pass validation.
async fn validate(session: &mut Session) -> Result<(), ImapError> { async fn validate(session: &mut Session) -> Result<(), ImapError> {
use async_imap::imap_proto::{Response, Status}; use async_imap::imap_proto::{Response, Status};
let id = session let id = session
@@ -98,7 +93,7 @@ impl PoolInner {
fn evict(&self) { fn evict(&self) {
self.users.lock().unwrap().retain(|_, user| { self.users.lock().unwrap().retain(|_, user| {
let mut idle = user.idle.lock().unwrap(); let mut idle = user.idle.lock().unwrap();
// Dropping a session closes its socket; never CLOSE (which expunges).
idle.retain(|(returned, _)| returned.elapsed() < IDLE_TTL); idle.retain(|(returned, _)| returned.elapsed() < IDLE_TTL);
!idle.is_empty() || Arc::strong_count(user) > 1 !idle.is_empty() || Arc::strong_count(user) > 1
}); });
@@ -106,8 +101,6 @@ impl PoolInner {
} }
impl ConnectionPool { impl ConnectionPool {
/// Drop all reusable request-path IMAP sessions for one account. Active
/// leases are detached from the pool and will be closed when returned.
pub(crate) fn clear_user(&self, creds: &ImapCredentials) { pub(crate) fn clear_user(&self, creds: &ImapCredentials) {
self.inner.users.lock().unwrap().remove(&identity(creds)); self.inner.users.lock().unwrap().remove(&identity(creds));
} }
@@ -139,7 +132,7 @@ impl ConnectionPool {
}) })
}) })
.clone(); .clone();
// Wait outside the shared budget so queued sync work cannot starve reads.
let background_permit = if BACKGROUND.try_with(|_| ()).is_ok() { let background_permit = if BACKGROUND.try_with(|_| ()).is_ok() {
Some( Some(
user.background_slots user.background_slots
@@ -189,7 +182,7 @@ impl ConnectionPool {
action = "connect", action = "connect",
"IMAP pool checkout" "IMAP pool checkout"
); );
// Bound TCP, TLS and LOGIN together, retaining connect's TCP timeout too.
let session = tokio::time::timeout(CHECK_TIMEOUT, connect(creds)) let session = tokio::time::timeout(CHECK_TIMEOUT, connect(creds))
.await .await
.map_err(|_| ImapError::ConnectionFailed("connection timed out".into()))??; .map_err(|_| ImapError::ConnectionFailed("connection timed out".into()))??;
@@ -202,8 +195,6 @@ impl ConnectionPool {
} }
} }
/// Defaults to discard, including when a request is cancelled mid-command.
/// Only explicitly completed operations may return the session for reuse.
pub(crate) struct Lease { pub(crate) struct Lease {
session: Option<Session>, session: Option<Session>,
user: Arc<UserPool>, user: Arc<UserPool>,
@@ -299,7 +290,6 @@ mod tests {
break; break;
} }
if line.contains(" UID FETCH ") && selected == "stalled" { if line.contains(" UID FETCH ") && selected == "stalled" {
// No completion. The caller must discard on cancellation/timeout.
continue; continue;
} }
let response = if line.contains(" GETQUOTAROOT ") { let response = if line.contains(" GETQUOTAROOT ") {
@@ -402,7 +392,7 @@ mod tests {
.is_err() .is_err()
); );
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS); assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS);
drop(leases.pop()); // Unfinished work must not be reused. drop(leases.pop());
let lease = pool.checkout(&creds).await.unwrap(); let lease = pool.checkout(&creds).await.unwrap();
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1); assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1);
lease.recycle(); lease.recycle();
@@ -478,7 +468,7 @@ mod tests {
for _ in 0..5 { for _ in 0..5 {
client.list_folders(&creds).await.unwrap(); client.list_folders(&creds).await.unwrap();
assert!(client.get_quota(&creds).await.unwrap().is_none()); assert!(client.get_quota(&creds).await.unwrap().is_none());
// A server without a configured quota triggers a size read per folder.
assert_eq!(client.fetch_folder_size(&creds, "INBOX").await.unwrap(), 0); assert_eq!(client.fetch_folder_size(&creds, "INBOX").await.unwrap(), 0);
assert_eq!( assert_eq!(
client.fetch_folder_size(&creds, "Archive").await.unwrap(), client.fetch_folder_size(&creds, "Archive").await.unwrap(),
+26 -44
View File
@@ -1,88 +1,76 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Represents an IMAP folder (mailbox).
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ImapFolder { pub struct ImapFolder {
/// Folder name as returned by the IMAP server (e.g. "INBOX", "Sent").
pub name: String, pub name: String,
/// Delimiter used by the server (e.g. "/" or ".").
pub delimiter: Option<String>, pub delimiter: Option<String>,
/// IMAP attributes for this folder (e.g. `\Noselect`, `\HasChildren`).
pub attributes: Vec<String>, pub attributes: Vec<String>,
} }
/// A parsed email address with optional display name.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EmailAddress { pub struct EmailAddress {
/// Display name, if present (e.g. "Alice Smith").
pub name: Option<String>, pub name: Option<String>,
/// The actual email address (e.g. "alice@example.com").
pub address: String, pub address: String,
} }
/// A lightweight summary of an email message (envelope data).
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ImapMessageHeader { pub struct ImapMessageHeader {
/// IMAP UID of the message within its folder.
pub uid: u32, pub uid: u32,
/// Subject line.
pub subject: Option<String>, pub subject: Option<String>,
/// Sender(s) of the message.
pub from: Vec<EmailAddress>, pub from: Vec<EmailAddress>,
/// Recipient(s) of the message.
pub to: Vec<EmailAddress>, pub to: Vec<EmailAddress>,
/// Date header value (raw string from the server).
pub date: Option<String>, pub date: Option<String>,
/// IMAP flags currently set on this message (e.g. `\Seen`, `\Flagged`).
pub flags: Vec<String>, pub flags: Vec<String>,
/// Whether this message has attachments (derived from BODYSTRUCTURE).
pub has_attachments: bool, pub has_attachments: bool,
/// RFC 2822 size of the message in bytes.
pub size: u32, pub size: u32,
/// Message-ID header value for threading.
pub message_id: Option<String>, pub message_id: Option<String>,
/// In-Reply-To header value for threading.
pub in_reply_to: Option<String>, pub in_reply_to: Option<String>,
/// References header value for threading.
pub references: Option<String>, pub references: Option<String>,
/// CC addresses.
pub cc: Vec<EmailAddress>, pub cc: Vec<EmailAddress>,
/// Reaction emoji if this is an Outlook/Exchange reaction notification.
pub reaction: Option<String>, pub reaction: Option<String>,
} }
/// The full body of an email message, including attachments.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ImapMessageBody { pub struct ImapMessageBody {
/// IMAP UID of the message within its folder.
pub uid: u32, pub uid: u32,
/// Plain-text body part, if available.
pub text_plain: Option<String>, pub text_plain: Option<String>,
/// HTML body part, if available.
pub text_html: Option<String>, pub text_html: Option<String>,
/// List of attachments found in the message.
pub attachments: Vec<ImapAttachment>, pub attachments: Vec<ImapAttachment>,
/// Raw RFC 822 headers as a single string.
pub raw_headers: String, pub raw_headers: String,
} }
/// Metadata about a single attachment in an email message.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ImapAttachment { pub struct ImapAttachment {
/// Filename of the attachment, if provided by the sender.
pub filename: Option<String>, pub filename: Option<String>,
/// MIME content type (e.g. "application/pdf").
pub content_type: String, pub content_type: String,
/// Size in bytes.
pub size: usize, pub size: usize,
/// Raw attachment content.
pub data: Vec<u8>, pub data: Vec<u8>,
/// Content-ID for inline images (e.g. "image001@01D1234"), without angle brackets.
pub content_id: Option<String>, pub content_id: Option<String>,
} }
/// Parameters needed to establish an IMAP connection.
/// Passed explicitly to every trait method so the trait stays stateless.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ImapCredentials { pub struct ImapCredentials {
pub host: String, pub host: String,
@@ -93,28 +81,22 @@ pub struct ImapCredentials {
pub password: String, pub password: String,
} }
/// Lightweight result of an IMAP `SELECT` command.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct FolderStatus { pub struct FolderStatus {
/// UIDVALIDITY - changes when the mailbox is rebuilt or UIDs are reassigned.
pub uid_validity: u32, pub uid_validity: u32,
/// The total number of messages currently in the folder.
pub exists: u32, pub exists: u32,
/// The highest UID that will be assigned to the next appended message.
pub uid_next: u32, pub uid_next: u32,
} }
/// Mailbox quota information from IMAP GETQUOTAROOT.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct MailboxQuota { pub struct MailboxQuota {
/// Storage used in bytes (STORAGE resource reports in KB, we convert).
pub usage_bytes: u64, pub usage_bytes: u64,
/// Storage limit in bytes.
pub limit_bytes: u64, pub limit_bytes: u64,
} }
/// Extended folder status from an IMAP `STATUS` command with CONDSTORE fields.
/// Used for cheap pre-checks before full sync.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct FolderStatusExtended { pub struct FolderStatusExtended {
pub uid_validity: u32, pub uid_validity: u32,
-10
View File
@@ -32,14 +32,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(()); return Ok(());
} }
// Initialize structured JSON logging with env filter.
// Default to INFO level; override with RUST_LOG env var.
tracing_subscriber::registry() tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(fmt::layer().json()) .with(fmt::layer().json())
.init(); .init();
// Load configuration via figment (serde defaults + env vars).
let loaded_config = AppConfig::load()?; let loaded_config = AppConfig::load()?;
loaded_config.validate().map_err(|message| { loaded_config.validate().map_err(|message| {
std::io::Error::new( std::io::Error::new(
@@ -53,13 +50,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
let config = Arc::new(loaded_config); let config = Arc::new(loaded_config);
// Create the in-memory session store with the configured timeout.
let store = Arc::new(SessionStore::new_with_tls_verify( let store = Arc::new(SessionStore::new_with_tls_verify(
Duration::from_secs(config.session_timeout_hours * 3600), Duration::from_secs(config.session_timeout_hours * 3600),
config.tls_verify, config.tls_verify,
)); ));
// Spawn a background task that periodically purges expired sessions.
{ {
let store = Arc::clone(&store); let store = Arc::clone(&store);
tokio::spawn(async move { tokio::spawn(async move {
@@ -72,20 +67,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
} }
// Create the IMAP and SMTP clients for production use.
let imap_client: Arc<dyn imap::client::ImapClient> = Arc::new(RealImapClient::default()); let imap_client: Arc<dyn imap::client::ImapClient> = Arc::new(RealImapClient::default());
let smtp_client: Arc<dyn smtp::client::SmtpClient> = Arc::new(RealSmtpClient); let smtp_client: Arc<dyn smtp::client::SmtpClient> = Arc::new(RealSmtpClient);
// Create the Tantivy search engine for full-text indexing.
let search_engine = Arc::new(search::engine::SearchEngine::new(std::path::PathBuf::from( let search_engine = Arc::new(search::engine::SearchEngine::new(std::path::PathBuf::from(
&config.data_dir, &config.data_dir,
))); )));
// Create the real-time event bus and IDLE manager.
let event_bus = Arc::new(realtime::events::EventBus::new()); let event_bus = Arc::new(realtime::events::EventBus::new());
let idle_manager = Arc::new(realtime::idle::IdleManager::new()); let idle_manager = Arc::new(realtime::idle::IdleManager::new());
// Build the application router with auth, session, and static file serving.
let app = routes::create_router( let app = routes::create_router(
config.clone(), config.clone(),
store, store,
@@ -96,7 +87,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
idle_manager.clone(), idle_manager.clone(),
); );
// Bind to the configured host and port.
let bind_addr = format!("{}:{}", config.host, config.port); let bind_addr = format!("{}:{}", config.host, config.port);
let listener = tokio::net::TcpListener::bind(&bind_addr).await?; let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
+1 -1
View File
@@ -126,7 +126,7 @@ async fn https_get_address(
format!("{host}:{port}") format!("{host}:{port}")
}; };
let request = format!( let request = format!(
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.4\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.0.5\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())) timeout(duration, stream.write_all(request.as_bytes()))
.await .await
-15
View File
@@ -78,8 +78,6 @@ pub fn generate_key(name: &str, email: &str, passphrase: &str) -> Result<Importe
} }
pub fn import_key(data: &[u8]) -> Result<ImportedKey, String> { pub fn import_key(data: &[u8]) -> Result<ImportedKey, String> {
// rPGP exposes separate helpers for ASCII armor and binary packet streams.
// Accept both so keys exported by GnuPG, Thunderbird and WKD/HKP all work.
let armored = data.starts_with(b"-----BEGIN PGP"); let armored = data.starts_with(b"-----BEGIN PGP");
if armored { if armored {
let text = std::str::from_utf8(data) let text = std::str::from_utf8(data)
@@ -171,9 +169,6 @@ pub fn sign_detached(data: &[u8], secret_armor: &str, passphrase: &str) -> Resul
} }
pub fn test_secret_key_passphrase(secret_armor: &str, passphrase: &str) -> Result<(), String> { pub fn test_secret_key_passphrase(secret_armor: &str, passphrase: &str) -> Result<(), String> {
// The key is never modified and the passphrase is not persisted. A short
// detached signature forces rPGP to unlock the private signing material.
// RustBox-generated keys always have a signing-capable primary key.
sign_detached( sign_detached(
b"RustBox OpenPGP unlock self-test", b"RustBox OpenPGP unlock self-test",
secret_armor, secret_armor,
@@ -374,9 +369,6 @@ pub fn store_secret_key(
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create PGP key directory: {e}"))?; fs::create_dir_all(&dir).map_err(|e| format!("Failed to create PGP key directory: {e}"))?;
set_dir_permissions(&dir)?; set_dir_permissions(&dir)?;
// Reject path/control punctuation before normalization. `normalize_fingerprint`
// intentionally strips formatting characters, so validating only its output
// would turn values such as "../../escape" into a short hexadecimal string.
if fingerprint.is_empty() if fingerprint.is_empty()
|| !fingerprint || !fingerprint
.chars() .chars()
@@ -410,9 +402,6 @@ pub fn delete_secret_key(path: &str) -> Result<(), String> {
} }
} }
/// Change the passphrase protecting an existing private key without exposing
/// passphrases in argv/environment. rPGP validates both the old and resulting
/// key; GnuPG is used only as a mature secret-packet re-wrapping engine.
pub fn change_secret_key_passphrase( pub fn change_secret_key_passphrase(
data_dir: &str, data_dir: &str,
user_hash: &str, user_hash: &str,
@@ -680,10 +669,6 @@ pub fn looks_like_openpgp(raw: &[u8]) -> bool {
|| lower.contains("-----begin pgp signed message-----") || lower.contains("-----begin pgp signed message-----")
} }
/// Process an OpenPGP/MIME or inline OpenPGP message. The caller supplies
/// private key armor candidates and the public keyring. Passphrases are never
/// persisted; one request may try the same supplied passphrase against every
/// candidate key.
pub fn process_message( pub fn process_message(
raw: &[u8], raw: &[u8],
secret_armors: &[String], secret_armors: &[String],
+8 -21
View File
@@ -3,29 +3,25 @@ use std::collections::HashMap;
use serde::Serialize; use serde::Serialize;
use tokio::sync::{RwLock, broadcast}; use tokio::sync::{RwLock, broadcast};
/// Events that can be pushed to connected WebSocket clients.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", content = "data")] #[serde(tag = "type", content = "data")]
#[allow(dead_code)] #[allow(dead_code)]
pub enum MailEvent { pub enum MailEvent {
/// New messages arrived in a folder.
NewMessages { NewMessages {
folder: String, folder: String,
count: u32, count: u32,
latest_uid: Option<u32>,
latest_sender: Option<String>, latest_sender: Option<String>,
latest_subject: Option<String>, latest_subject: Option<String>,
}, },
/// Flags changed on messages in a folder.
FlagsChanged { folder: String }, FlagsChanged {
/// Folder list or counts changed. folder: String,
},
FolderUpdated, FolderUpdated,
} }
/// Fan-out event bus: per-user broadcast channels.
///
/// Each user (keyed by `user_hash`) gets a `broadcast::Sender` that can have
/// multiple receivers (one per WebSocket connection). When all receivers are
/// dropped the channel is cleaned up.
pub struct EventBus { pub struct EventBus {
channels: RwLock<HashMap<String, broadcast::Sender<MailEvent>>>, channels: RwLock<HashMap<String, broadcast::Sender<MailEvent>>>,
} }
@@ -37,10 +33,7 @@ impl EventBus {
} }
} }
/// Subscribe to events for a user. Creates the channel if it doesn't exist.
/// Returns a receiver that will receive all future events for this user.
pub async fn subscribe(&self, user_hash: &str) -> broadcast::Receiver<MailEvent> { pub async fn subscribe(&self, user_hash: &str) -> broadcast::Receiver<MailEvent> {
// Fast path: read lock.
{ {
let channels = self.channels.read().await; let channels = self.channels.read().await;
if let Some(tx) = channels.get(user_hash) { if let Some(tx) = channels.get(user_hash) {
@@ -48,7 +41,6 @@ impl EventBus {
} }
} }
// Slow path: write lock to insert.
let mut channels = self.channels.write().await; let mut channels = self.channels.write().await;
let tx = channels.entry(user_hash.to_string()).or_insert_with(|| { let tx = channels.entry(user_hash.to_string()).or_insert_with(|| {
let (tx, _) = broadcast::channel(64); let (tx, _) = broadcast::channel(64);
@@ -57,22 +49,16 @@ impl EventBus {
tx.subscribe() tx.subscribe()
} }
/// Publish an event to all connected clients for a user.
/// No-op if no one is listening.
pub async fn publish(&self, user_hash: &str, event: MailEvent) { pub async fn publish(&self, user_hash: &str, event: MailEvent) {
let channels = self.channels.read().await; let channels = self.channels.read().await;
if let Some(tx) = channels.get(user_hash) { if let Some(tx) = channels.get(user_hash) {
// Ignore error (no receivers connected).
let _ = tx.send(event); let _ = tx.send(event);
} }
} }
/// Remove a user's channel when no more connections exist.
/// Called when the last WebSocket for a user disconnects.
pub async fn cleanup(&self, user_hash: &str) { pub async fn cleanup(&self, user_hash: &str) {
let mut channels = self.channels.write().await; let mut channels = self.channels.write().await;
if let Some(tx) = channels.get(user_hash) { if let Some(tx) = channels.get(user_hash) {
// Only remove if no active receivers remain.
if tx.receiver_count() == 0 { if tx.receiver_count() == 0 {
channels.remove(user_hash); channels.remove(user_hash);
} }
@@ -101,6 +87,7 @@ mod tests {
MailEvent::NewMessages { MailEvent::NewMessages {
folder: "INBOX".to_string(), folder: "INBOX".to_string(),
count: 1, count: 1,
latest_uid: Some(42),
latest_sender: Some("alice@example.com".to_string()), latest_sender: Some("alice@example.com".to_string()),
latest_subject: Some("Hello".to_string()), latest_subject: Some("Hello".to_string()),
}, },
@@ -120,7 +107,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn publish_to_nonexistent_user_is_noop() { async fn publish_to_nonexistent_user_is_noop() {
let bus = EventBus::new(); let bus = EventBus::new();
// Should not panic.
bus.publish("ghost", MailEvent::FolderUpdated).await; bus.publish("ghost", MailEvent::FolderUpdated).await;
} }
+16 -80
View File
@@ -11,8 +11,6 @@ use crate::imap::parse::{decode_rfc2047, flag_to_string, has_attachments, imap_a
use crate::realtime::events::{EventBus, MailEvent}; use crate::realtime::events::{EventBus, MailEvent};
use crate::{db, imap}; use crate::{db, imap};
/// Manages long-lived IMAP IDLE connections, one per (user, folder) pair.
/// Each WebSocket holds a subscription; only the last disconnect stops IDLE.
pub struct IdleManager { pub struct IdleManager {
tasks: DashMap<(String, String), IdleTask>, tasks: DashMap<(String, String), IdleTask>,
} }
@@ -29,7 +27,6 @@ impl IdleManager {
} }
} }
/// Subscribe to IDLE for a user + folder, sharing an existing task.
pub async fn start_idle( pub async fn start_idle(
&self, &self,
user_hash: String, user_hash: String,
@@ -56,7 +53,6 @@ impl IdleManager {
} }
} }
/// Release this socket's subscription, without stopping other sockets.
pub async fn stop_idle(&self, user_hash: &str, folder: &str) { pub async fn stop_idle(&self, user_hash: &str, folder: &str) {
use dashmap::mapref::entry::Entry; use dashmap::mapref::entry::Entry;
@@ -70,7 +66,6 @@ impl IdleManager {
} }
} }
/// Abort all dedicated IDLE tasks during process shutdown.
pub fn stop_all(&self) { pub fn stop_all(&self) {
for task in self.tasks.iter() { for task in self.tasks.iter() {
task.value().handle.abort(); task.value().handle.abort();
@@ -93,12 +88,6 @@ impl Default for IdleManager {
} }
} }
/// The inner IDLE loop with auto-reconnect and exponential backoff.
///
/// This opens a dedicated IMAP connection, SELECTs the folder, and enters
/// IDLE mode. When the server notifies of changes, it publishes an event
/// to the EventBus and re-enters IDLE. If the connection drops, it
/// reconnects with exponential backoff.
async fn idle_loop( async fn idle_loop(
user_hash: &str, user_hash: &str,
folder: &str, folder: &str,
@@ -112,7 +101,6 @@ async fn idle_loop(
loop { loop {
match run_idle_session(user_hash, folder, creds, event_bus, config).await { match run_idle_session(user_hash, folder, creds, event_bus, config).await {
Ok(()) => { Ok(()) => {
// Session ended normally (shouldn't happen in practice).
tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session ended normally"); tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session ended normally");
break; break;
} }
@@ -131,11 +119,6 @@ async fn idle_loop(
} }
} }
/// Run a single IDLE session. Returns Err on connection/protocol errors.
///
/// The async-imap IDLE API follows an ownership pattern:
/// `Session` → `.idle()` consumes session → `Handle` → `.init()` sends IDLE
/// → `.wait()` listens → `.done()` sends DONE and returns `Session`.
async fn run_idle_session( async fn run_idle_session(
user_hash: &str, user_hash: &str,
folder: &str, folder: &str,
@@ -156,31 +139,22 @@ async fn run_idle_session(
tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session started"); tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session started");
// Re-enter IDLE every 25 minutes (RFC recommends max 29 min).
let idle_timeout = std::time::Duration::from_secs(25 * 60); let idle_timeout = std::time::Duration::from_secs(25 * 60);
loop { loop {
// `.idle()` consumes the session, wrapping it in a Handle.
let mut idle_handle = session.idle(); let mut idle_handle = session.idle();
// Initialize the IDLE command with the server.
idle_handle idle_handle
.init() .init()
.await .await
.map_err(|e| format!("IDLE init failed: {e}"))?; .map_err(|e| format!("IDLE init failed: {e}"))?;
// Start listening for server notifications.
// IMPORTANT: `interrupt` must be kept alive — dropping it triggers
// ManualInterrupt and ends the wait immediately.
let (idle_wait, interrupt) = idle_handle.wait(); let (idle_wait, interrupt) = idle_handle.wait();
// Wait for the server to send an unsolicited response, or timeout.
let result = tokio::time::timeout(idle_timeout, idle_wait).await; let result = tokio::time::timeout(idle_timeout, idle_wait).await;
// Drop the interrupt handle now that we're done waiting.
drop(interrupt); drop(interrupt);
// Send DONE to end IDLE and get the session back.
session = idle_handle session = idle_handle
.done() .done()
.await .await
@@ -197,14 +171,10 @@ async fn run_idle_session(
"IDLE: new data from server" "IDLE: new data from server"
); );
// CONDSTORE sync is fast (~200-500ms), so run it
// BEFORE publishing. This ensures the DB is up-to-date
// when the frontend refetches.
let result; let result;
(session, result) = (session, result) =
fetch_new_after_idle(session, user_hash, folder, config).await; fetch_new_after_idle(session, user_hash, folder, config).await;
// Publish the correct event based on what changed.
if result.new_count > 0 { if result.new_count > 0 {
event_bus event_bus
.publish( .publish(
@@ -212,6 +182,7 @@ async fn run_idle_session(
MailEvent::NewMessages { MailEvent::NewMessages {
folder: folder.to_string(), folder: folder.to_string(),
count: result.new_count, count: result.new_count,
latest_uid: result.latest_uid,
latest_sender: result.latest_sender, latest_sender: result.latest_sender,
latest_subject: result.latest_subject, latest_subject: result.latest_subject,
}, },
@@ -245,11 +216,9 @@ async fn run_idle_session(
} }
} }
Ok(Err(e)) => { Ok(Err(e)) => {
// IDLE protocol error.
return Err(format!("IDLE error: {e}")); return Err(format!("IDLE error: {e}"));
} }
Err(_) => { Err(_) => {
// Our timeout — re-enter IDLE to keep the connection alive.
tracing::debug!( tracing::debug!(
user_hash = %user_hash, user_hash = %user_hash,
folder = %folder, folder = %folder,
@@ -260,11 +229,11 @@ async fn run_idle_session(
} }
} }
/// Summary of what changed during a post-IDLE reconciliation.
struct ReconcileResult { struct ReconcileResult {
new_count: u32, new_count: u32,
flags_updated: u32, flags_updated: u32,
deleted_count: u32, deleted_count: u32,
latest_uid: Option<u32>,
latest_sender: Option<String>, latest_sender: Option<String>,
latest_subject: Option<String>, latest_subject: Option<String>,
} }
@@ -275,26 +244,19 @@ impl ReconcileResult {
new_count: 0, new_count: 0,
flags_updated: 0, flags_updated: 0,
deleted_count: 0, deleted_count: 0,
latest_uid: None,
latest_sender: None, latest_sender: None,
latest_subject: None, latest_subject: None,
} }
} }
} }
/// Reconcile the local cache after IDLE detects new data.
///
/// Uses CONDSTORE when available for fast incremental sync (~200-500ms),
/// falling back to a full UID scan when CONDSTORE is not supported.
///
/// If anything fails, it logs a warning and returns the session unchanged —
/// the periodic sync loop provides a safety net.
async fn fetch_new_after_idle( async fn fetch_new_after_idle(
mut session: async_imap::Session<ImapStream>, mut session: async_imap::Session<ImapStream>,
user_hash: &str, user_hash: &str,
folder: &str, folder: &str,
config: &AppConfig, config: &AppConfig,
) -> (async_imap::Session<ImapStream>, ReconcileResult) { ) -> (async_imap::Session<ImapStream>, ReconcileResult) {
// Read cached folder state from DB.
let (cached_modseq, max_cached_uid, cached_count, cached_uid_validity, header_sync) = let (cached_modseq, max_cached_uid, cached_count, cached_uid_validity, header_sync) =
match db::pool::open_user_db(&config.data_dir, user_hash) { match db::pool::open_user_db(&config.data_dir, user_hash) {
Ok(conn) => { Ok(conn) => {
@@ -318,7 +280,6 @@ async fn fetch_new_after_idle(
} }
}; };
// Re-SELECT with CONDSTORE to get fresh mailbox state.
let mailbox = match session.select_condstore(folder).await { let mailbox = match session.select_condstore(folder).await {
Ok(mb) => mb, Ok(mb) => mb,
Err(e) => { Err(e) => {
@@ -331,7 +292,6 @@ async fn fetch_new_after_idle(
let server_modseq = mailbox.highest_modseq.unwrap_or(0); let server_modseq = mailbox.highest_modseq.unwrap_or(0);
let uid_validity = mailbox.uid_validity.unwrap_or(0); let uid_validity = mailbox.uid_validity.unwrap_or(0);
// Let list sync reset the namespace before IDLE writes any reused UIDs.
if cached_uid_validity != 0 && cached_uid_validity != uid_validity { if cached_uid_validity != 0 && cached_uid_validity != uid_validity {
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) { if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) {
let _ = db::folders::invalidate_folder_freshness(&conn, folder); let _ = db::folders::invalidate_folder_freshness(&conn, folder);
@@ -339,7 +299,6 @@ async fn fetch_new_after_idle(
return (session, ReconcileResult::empty()); return (session, ReconcileResult::empty());
} }
// Early exit: nothing changed (CONDSTORE modseq and count both match).
if server_modseq > 0 && server_modseq == cached_modseq && server_exists == cached_count { if server_modseq > 0 && server_modseq == cached_modseq && server_exists == cached_count {
tracing::debug!( tracing::debug!(
user_hash = %user_hash, user_hash = %user_hash,
@@ -350,7 +309,6 @@ async fn fetch_new_after_idle(
return (session, ReconcileResult::empty()); return (session, ReconcileResult::empty());
} }
// ── CONDSTORE fast path ──────────────────────────────────────────
if cached_modseq > 0 && server_modseq > 0 { if cached_modseq > 0 && server_modseq > 0 {
let fetch_query = format!("(UID FLAGS) (CHANGEDSINCE {})", cached_modseq); let fetch_query = format!("(UID FLAGS) (CHANGEDSINCE {})", cached_modseq);
let changed_items: Option<Vec<(u32, Vec<String>)>> = let changed_items: Option<Vec<(u32, Vec<String>)>> =
@@ -388,7 +346,6 @@ async fn fetch_new_after_idle(
}; };
if let Some(changed) = changed_items { if let Some(changed) = changed_items {
// Separate flag changes (uid ≤ max) from new messages (uid > max).
let mut flag_changes: Vec<(u32, Vec<String>)> = Vec::new(); let mut flag_changes: Vec<(u32, Vec<String>)> = Vec::new();
let mut has_new = false; let mut has_new = false;
for (uid, flags) in changed { for (uid, flags) in changed {
@@ -399,7 +356,6 @@ async fn fetch_new_after_idle(
} }
} }
// Apply flag changes to DB.
let mut flags_updated = 0u32; let mut flags_updated = 0u32;
if !flag_changes.is_empty() if !flag_changes.is_empty()
&& let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) && let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
@@ -424,7 +380,6 @@ async fn fetch_new_after_idle(
} }
} }
// Fetch full headers for new messages.
let mut new_count = 0u32; let mut new_count = 0u32;
if has_new { if has_new {
let uid_range = format!("{}:*", max_cached_uid + 1); let uid_range = format!("{}:*", max_cached_uid + 1);
@@ -440,21 +395,18 @@ async fn fetch_new_after_idle(
.await; .await;
} }
// Detect deletions if server count < cached count.
let mut deleted_count = 0u32; let mut deleted_count = 0u32;
if server_exists < cached_count { if server_exists < cached_count {
deleted_count = deleted_count =
fetch_uids_and_remove_deleted(&mut session, user_hash, folder, config).await; fetch_uids_and_remove_deleted(&mut session, user_hash, folder, config).await;
} }
// Look up the latest message's sender/subject if new messages arrived. let (latest_uid, latest_sender, latest_subject) = if new_count > 0 {
let (latest_sender, latest_subject) = if new_count > 0 {
lookup_latest_message(config, user_hash, folder) lookup_latest_message(config, user_hash, folder)
} else { } else {
(None, None) (None, None, None)
}; };
// Update folder sync status with new modseq.
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
&& let Ok(transaction) = header_sync.begin_write(&conn) && let Ok(transaction) = header_sync.begin_write(&conn)
{ {
@@ -486,15 +438,14 @@ async fn fetch_new_after_idle(
new_count, new_count,
flags_updated, flags_updated,
deleted_count, deleted_count,
latest_uid,
latest_sender, latest_sender,
latest_subject, latest_subject,
}, },
); );
} }
// CHANGEDSINCE fetch failed — fall through to full scan below.
} }
// ── Fallback: full UID scan (no CONDSTORE) ───────────────────────
let cached_uids: std::collections::HashSet<u32> = let cached_uids: std::collections::HashSet<u32> =
match db::pool::open_user_db(&config.data_dir, user_hash) { match db::pool::open_user_db(&config.data_dir, user_hash) {
Ok(conn) => { Ok(conn) => {
@@ -508,7 +459,6 @@ async fn fetch_new_after_idle(
} }
}; };
// Fetch all UIDs from server.
let imap_uids: Option<std::collections::HashSet<u32>> = let imap_uids: Option<std::collections::HashSet<u32>> =
match session.uid_fetch("1:*", "UID").await { match session.uid_fetch("1:*", "UID").await {
Ok(stream) => { Ok(stream) => {
@@ -539,7 +489,6 @@ async fn fetch_new_after_idle(
return (session, ReconcileResult::empty()); return (session, ReconcileResult::empty());
}; };
// Fetch headers for new messages.
let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid); let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid);
let mut new_count = 0u32; let mut new_count = 0u32;
if has_new { if has_new {
@@ -556,7 +505,6 @@ async fn fetch_new_after_idle(
.await; .await;
} }
// Delete cached UIDs no longer on server.
let mut deleted_count = 0u32; let mut deleted_count = 0u32;
let deleted_uids: Vec<u32> = cached_uids let deleted_uids: Vec<u32> = cached_uids
.iter() .iter()
@@ -573,7 +521,6 @@ async fn fetch_new_after_idle(
} }
} }
// Update folder sync status.
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
&& let Ok(transaction) = header_sync.begin_write(&conn) && let Ok(transaction) = header_sync.begin_write(&conn)
{ {
@@ -588,11 +535,10 @@ async fn fetch_new_after_idle(
let _ = transaction.commit(); let _ = transaction.commit();
} }
// Look up the latest message's sender/subject if new messages arrived. let (latest_uid, latest_sender, latest_subject) = if new_count > 0 {
let (latest_sender, latest_subject) = if new_count > 0 {
lookup_latest_message(config, user_hash, folder) lookup_latest_message(config, user_hash, folder)
} else { } else {
(None, None) (None, None, None)
}; };
if new_count > 0 || deleted_count > 0 { if new_count > 0 || deleted_count > 0 {
@@ -611,24 +557,24 @@ async fn fetch_new_after_idle(
new_count, new_count,
flags_updated: 0, flags_updated: 0,
deleted_count, deleted_count,
latest_uid,
latest_sender, latest_sender,
latest_subject, latest_subject,
}, },
) )
} }
/// Look up the most recent message in the DB for sender/subject details.
fn lookup_latest_message( fn lookup_latest_message(
config: &AppConfig, config: &AppConfig,
user_hash: &str, user_hash: &str,
folder: &str, folder: &str,
) -> (Option<String>, Option<String>) { ) -> (Option<u32>, Option<String>, Option<String>) {
let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) else { let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) else {
return (None, None); return (None, None, None);
}; };
let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0); let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0);
if max_uid == 0 { if max_uid == 0 {
return (None, None); return (None, None, None);
} }
match db::messages::get_single_message(&conn, folder, max_uid) { match db::messages::get_single_message(&conn, folder, max_uid) {
Ok(Some(msg)) => { Ok(Some(msg)) => {
@@ -644,15 +590,12 @@ fn lookup_latest_message(
} else { } else {
None None
}; };
(sender, subject) (Some(max_uid), sender, subject)
} }
_ => (None, None), _ => (None, None, None),
} }
} }
/// Fetch full headers for messages in the given UID range and store them in the DB.
/// Skips messages with UID ≤ `min_uid` (handles the `*` wildcard returning the last message).
/// Returns the number of new messages stored.
async fn fetch_and_store_headers( async fn fetch_and_store_headers(
session: &mut async_imap::Session<ImapStream>, session: &mut async_imap::Session<ImapStream>,
user_hash: &str, user_hash: &str,
@@ -706,11 +649,10 @@ async fn fetch_and_store_headers(
for fetch in &fetches { for fetch in &fetches {
let uid = match fetch.uid { let uid = match fetch.uid {
Some(u) if u > min_uid => u, Some(u) if u > min_uid => u,
Some(_) => continue, // UID "*" can return the last message Some(_) => continue,
None => continue, None => continue,
}; };
// Parse threading headers.
let raw_header_bytes = fetch.header(); let raw_header_bytes = fetch.header();
let parsed_threading = let parsed_threading =
raw_header_bytes.and_then(|raw| mail_parser::MessageParser::default().parse(raw)); raw_header_bytes.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
@@ -770,7 +712,6 @@ async fn fetch_and_store_headers(
.or_else(|| val.as_text().map(|s| format!("<{s}>"))) .or_else(|| val.as_text().map(|s| format!("<{s}>")))
}); });
// Detect Outlook/Exchange reaction emails from headers.
let reaction = raw_header_bytes.and_then(|raw| { let reaction = raw_header_bytes.and_then(|raw| {
let header_str = std::str::from_utf8(raw).ok()?; let header_str = std::str::from_utf8(raw).ok()?;
let lower = header_str.to_lowercase(); let lower = header_str.to_lowercase();
@@ -828,7 +769,6 @@ async fn fetch_and_store_headers(
) { ) {
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to upsert message"); tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to upsert message");
} else { } else {
// Populate denormalized known_addresses table.
if let Err(e) = db::contacts::populate_known_addresses( if let Err(e) = db::contacts::populate_known_addresses(
&conn, &conn,
from_address, from_address,
@@ -855,9 +795,6 @@ async fn fetch_and_store_headers(
new_count new_count
} }
/// Fetch all UIDs from the server and remove cached messages no longer present.
/// Used for deletion detection when server count < cached count.
/// Returns the number of deleted messages.
async fn fetch_uids_and_remove_deleted( async fn fetch_uids_and_remove_deleted(
session: &mut async_imap::Session<ImapStream>, session: &mut async_imap::Session<ImapStream>,
user_hash: &str, user_hash: &str,
@@ -943,7 +880,7 @@ mod tests {
) )
.await; .await;
let original_task = manager.tasks.get(&key).unwrap().handle.id(); let original_task = manager.tasks.get(&key).unwrap().handle.id();
// A reconnect may subscribe before the old socket finishes closing.
manager manager
.start_idle( .start_idle(
key.0.clone(), key.0.clone(),
@@ -963,7 +900,6 @@ mod tests {
manager.stop_idle(&key.0, &key.1).await; manager.stop_idle(&key.0, &key.1).await;
assert!(manager.tasks.is_empty()); assert!(manager.tasks.is_empty());
// A connection arriving after the last disconnect gets a new task.
manager manager
.start_idle(key.0.clone(), key.1.clone(), creds, event_bus, config) .start_idle(key.0.clone(), key.1.clone(), creds, event_bus, config)
.await; .await;
+2 -22
View File
@@ -9,16 +9,8 @@ use crate::realtime::events::{EventBus, MailEvent};
use crate::search::engine::UserIndex; use crate::search::engine::UserIndex;
use crate::search::engine::{IndexableMessage, SearchEngine}; use crate::search::engine::{IndexableMessage, SearchEngine};
/// How often to run a sync check (seconds).
/// Reconcile every three minutes, including an INBOX safety net if IDLE drops.
const SYNC_INTERVAL_SECS: u64 = 180; const SYNC_INTERVAL_SECS: u64 = 180;
/// Run a periodic reconciliation loop for a user.
///
/// Uses a 3-tier strategy per folder:
/// 1. STATUS pre-check (cheap, no SELECT)
/// 2. CONDSTORE incremental fetch (only changed flags)
/// 3. Full fetch fallback (when CONDSTORE unavailable)
pub async fn sync_loop( pub async fn sync_loop(
user_hash: String, user_hash: String,
creds: ImapCredentials, creds: ImapCredentials,
@@ -28,7 +20,7 @@ pub async fn sync_loop(
search_engine: Arc<SearchEngine>, search_engine: Arc<SearchEngine>,
) { ) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(SYNC_INTERVAL_SECS)); let mut interval = tokio::time::interval(std::time::Duration::from_secs(SYNC_INTERVAL_SECS));
// Skip the first immediate tick — IDLE + initial list_messages handles that.
interval.tick().await; interval.tick().await;
loop { loop {
@@ -80,7 +72,6 @@ async fn run_sync(
for folder in &folder_snapshots { for folder in &folder_snapshots {
let folder_name = &folder.name; let folder_name = &folder.name;
// ── Tier 1: STATUS pre-check (no SELECT needed) ──────────────
let status = match imap_client.folder_status_extended(creds, folder_name).await { let status = match imap_client.folder_status_extended(creds, folder_name).await {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
@@ -93,7 +84,6 @@ async fn run_sync(
} }
}; };
// Check UIDVALIDITY — if changed, the folder was rebuilt.
if folder.uid_validity != 0 && status.uid_validity != folder.uid_validity { if folder.uid_validity != 0 && status.uid_validity != folder.uid_validity {
tracing::info!( tracing::info!(
folder = %folder_name, folder = %folder_name,
@@ -110,7 +100,6 @@ async fn run_sync(
continue; continue;
} }
// If CONDSTORE is supported (highest_modseq > 0) and matches cached → skip.
let cached_modseq = folder.highest_modseq; let cached_modseq = folder.highest_modseq;
if status.highest_modseq > 0 && cached_modseq > 0 && status.highest_modseq == cached_modseq if status.highest_modseq > 0 && cached_modseq > 0 && status.highest_modseq == cached_modseq
{ {
@@ -128,7 +117,6 @@ async fn run_sync(
} }
} }
// ── Tier 2: CONDSTORE incremental fetch ──────────────────────
if status.highest_modseq > 0 && cached_modseq > 0 { if status.highest_modseq > 0 && cached_modseq > 0 {
let folder_changed = sync_condstore( let folder_changed = sync_condstore(
config, config,
@@ -147,7 +135,6 @@ async fn run_sync(
continue; continue;
} }
// ── Tier 3: Full fetch fallback ──────────────────────────────
let folder_changed = sync_full( let folder_changed = sync_full(
config, config,
user_hash, user_hash,
@@ -167,7 +154,6 @@ async fn run_sync(
event_bus.publish(user_hash, MailEvent::FolderUpdated).await; event_bus.publish(user_hash, MailEvent::FolderUpdated).await;
} }
// ── Deep index phase: fetch & index bodies if enabled ────────────
if let Err(e) = index_message_bodies(user_hash, creds, config, imap_client, search_engine).await if let Err(e) = index_message_bodies(user_hash, creds, config, imap_client, search_engine).await
{ {
tracing::debug!(error = %e, "Deep index phase skipped or failed"); tracing::debug!(error = %e, "Deep index phase skipped or failed");
@@ -176,11 +162,8 @@ async fn run_sync(
Ok(()) Ok(())
} }
/// Batch size for deep indexing per sync cycle.
const DEEP_INDEX_BATCH: u32 = 10; const DEEP_INDEX_BATCH: u32 = 10;
/// If the `deep_index` preference is enabled, fetch bodies for messages that
/// don't have a cached body yet, cache them, and re-index with body text.
async fn index_message_bodies( async fn index_message_bodies(
user_hash: &str, user_hash: &str,
creds: &ImapCredentials, creds: &ImapCredentials,
@@ -265,14 +248,12 @@ async fn index_message_bodies(
Ok(()) Ok(())
} }
/// Lightweight snapshot of a cached folder for the sync loop.
struct FolderSnapshot { struct FolderSnapshot {
name: String, name: String,
uid_validity: u32, uid_validity: u32,
highest_modseq: u64, highest_modseq: u64,
} }
/// CONDSTORE path: fetch only changed flags, detect deletions via count comparison.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn sync_condstore( async fn sync_condstore(
config: &AppConfig, config: &AppConfig,
@@ -284,7 +265,6 @@ async fn sync_condstore(
status: &crate::imap::types::FolderStatusExtended, status: &crate::imap::types::FolderStatusExtended,
event_bus: &EventBus, event_bus: &EventBus,
) -> Result<bool, String> { ) -> Result<bool, String> {
// Fetch only messages whose flags changed since our cached modseq.
let (changed, new_modseq) = match imap_client let (changed, new_modseq) = match imap_client
.fetch_changed_flags(creds, folder_name, cached_modseq) .fetch_changed_flags(creds, folder_name, cached_modseq)
.await .await
@@ -380,6 +360,7 @@ async fn sync_condstore(
MailEvent::NewMessages { MailEvent::NewMessages {
folder: folder_name.to_owned(), folder: folder_name.to_owned(),
count: 0, count: 0,
latest_uid: None,
latest_sender: None, latest_sender: None,
latest_subject: None, latest_subject: None,
}, },
@@ -401,7 +382,6 @@ async fn sync_condstore(
Ok(folder_changed) Ok(folder_changed)
} }
/// Full fetch fallback: fetch all UIDs+FLAGS and reconcile (original behavior).
async fn sync_full( async fn sync_full(
config: &AppConfig, config: &AppConfig,
user_hash: &str, user_hash: &str,
+25 -5
View File
@@ -14,6 +14,13 @@ use crate::realtime::events::EventBus;
use crate::realtime::idle::IdleManager; use crate::realtime::idle::IdleManager;
use crate::search::engine::SearchEngine; use crate::search::engine::SearchEngine;
fn unix_time_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
fn extract_browser_id(cookie_header: &str) -> Option<String> { fn extract_browser_id(cookie_header: &str) -> Option<String> {
for segment in cookie_header.split(';') { for segment in cookie_header.split(';') {
let trimmed = segment.trim(); let trimmed = segment.trim();
@@ -116,7 +123,7 @@ async fn handle_socket_multi_account(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify, tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
@@ -145,13 +152,17 @@ async fn handle_socket_multi_account(
drop(event_tx); drop(event_tx);
let (mut ws_tx, mut ws_rx) = socket.split(); let (mut ws_tx, mut ws_rx) = socket.split();
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); let mut heartbeat_interval = tokio::time::interval(std::time::Duration::from_secs(25));
heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_pong = tokio::time::Instant::now();
loop { loop {
tokio::select! { tokio::select! {
Some((account_id, mail_event)) = event_rx.recv() => { Some((account_id, mail_event)) = event_rx.recv() => {
let msg = serde_json::json!({ let msg = serde_json::json!({
"accountId": account_id, "accountId": account_id,
"eventId": uuid::Uuid::new_v4().to_string(),
"sentAt": unix_time_millis(),
"event": mail_event, "event": mail_event,
}); });
if let Ok(json) = serde_json::to_string(&msg) if let Ok(json) = serde_json::to_string(&msg)
@@ -164,14 +175,23 @@ async fn handle_socket_multi_account(
msg = ws_rx.next() => { msg = ws_rx.next() => {
match msg { match msg {
Some(Ok(Message::Close(_))) | None => break, Some(Ok(Message::Close(_))) | None => break,
Some(Ok(Message::Pong(_))) => {} Some(Ok(Message::Pong(_))) => { last_pong = tokio::time::Instant::now(); }
Some(Ok(Message::Text(text))) => {
if serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|value| value.get("type").and_then(|kind| kind.as_str()).map(str::to_owned)).as_deref() == Some("heartbeat_ack") {
last_pong = tokio::time::Instant::now();
}
}
Some(Ok(_)) => {} Some(Ok(_)) => {}
Some(Err(_)) => break, Some(Err(_)) => break,
} }
} }
_ = ping_interval.tick() => { _ = heartbeat_interval.tick() => {
if ws_tx.send(Message::Ping(vec![].into())).await.is_err() { if last_pong.elapsed() > std::time::Duration::from_secs(75) {
break;
}
let heartbeat = serde_json::json!({ "type": "heartbeat", "timestamp": unix_time_millis() }).to_string();
if ws_tx.send(Message::Text(heartbeat.into())).await.is_err() || ws_tx.send(Message::Ping(Vec::new().into())).await.is_err() {
break; break;
} }
} }
-15
View File
@@ -55,10 +55,6 @@ struct DeleteResponse {
status: String, status: String,
} }
/// Handler for `POST /api/drafts/{draft_id}/attachments`.
///
/// Accepts a multipart upload containing one or more files. Each file is
/// saved to disk and recorded in the `draft_attachments` table.
pub async fn upload_attachment( pub async fn upload_attachment(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -69,7 +65,6 @@ pub async fn upload_attachment(
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
// Ensure draft exists (create it if not — auto-creates on first attachment upload).
let existing = db::drafts::get_draft(&conn, &draft_id).map_err(AppError::InternalError)?; let existing = db::drafts::get_draft(&conn, &draft_id).map_err(AppError::InternalError)?;
if existing.is_none() { if existing.is_none() {
db::drafts::upsert_draft( db::drafts::upsert_draft(
@@ -79,7 +74,6 @@ pub async fn upload_attachment(
.map_err(AppError::InternalError)?; .map_err(AppError::InternalError)?;
} }
// Build the attachment storage directory.
let att_dir = Path::new(&config.data_dir) let att_dir = Path::new(&config.data_dir)
.join(&session.user_hash) .join(&session.user_hash)
.join("attachments") .join("attachments")
@@ -186,9 +180,6 @@ pub async fn upload_attachment(
.into_response()) .into_response())
} }
/// Handler for `GET /api/drafts/{draft_id}/attachments/{attachment_id}/content`.
///
/// Serves the raw file content of a draft attachment for inline preview.
pub async fn get_attachment_content( pub async fn get_attachment_content(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -217,9 +208,6 @@ pub async fn get_attachment_content(
.unwrap()) .unwrap())
} }
/// Handler for `DELETE /api/drafts/{draft_id}/attachments/{attachment_id}`.
///
/// Removes an attachment from the database and deletes the file from disk.
pub async fn delete_attachment( pub async fn delete_attachment(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -229,7 +217,6 @@ pub async fn delete_attachment(
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
// Get the attachment record so we can find the file path.
let attachments = let attachments =
db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?; db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?;
@@ -238,13 +225,11 @@ pub async fn delete_attachment(
.find(|a| a.id == attachment_id) .find(|a| a.id == attachment_id)
.ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?; .ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?;
// Delete file from disk (best-effort).
let file_path = attachment.file_path.clone(); let file_path = attachment.file_path.clone();
if let Err(e) = tokio::fs::remove_file(&file_path).await { if let Err(e) = tokio::fs::remove_file(&file_path).await {
tracing::warn!(error = %e, path = %file_path, "Failed to delete attachment file from disk"); tracing::warn!(error = %e, path = %file_path, "Failed to delete attachment file from disk");
} }
// Delete from DB.
db::drafts::delete_draft_attachment(&conn, &attachment_id).map_err(AppError::InternalError)?; db::drafts::delete_draft_attachment(&conn, &attachment_id).map_err(AppError::InternalError)?;
Ok(Json(DeleteResponse { Ok(Json(DeleteResponse {
-27
View File
@@ -30,7 +30,6 @@ pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<A
}) })
} }
/// JSON body expected on `POST /api/auth/login`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LoginRequest { pub struct LoginRequest {
pub email: String, pub email: String,
@@ -41,7 +40,6 @@ pub struct LoginRequest {
} }
fn same_site_policy(_config: &AppConfig) -> &'static str { fn same_site_policy(_config: &AppConfig) -> &'static str {
// RustBox UI and API are same-origin in RustBox, so Strict is always valid.
"Strict" "Strict"
} }
@@ -138,11 +136,6 @@ fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, Stri
sessions sessions
} }
/// `POST /api/auth/login`
///
/// Validates the user's credentials against the configured IMAP server.
/// On success, creates a session, provisions the user's data directory,
/// and returns session cookies.
pub async fn login( pub async fn login(
Extension(store): Extension<Arc<SessionStore>>, Extension(store): Extension<Arc<SessionStore>>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -165,10 +158,6 @@ pub async fn login(
.into_response(); .into_response();
} }
// Server endpoints are administrator-owned configuration. The login
// request supplies only user credentials; per-request host overrides are
// intentionally ignored so credentials cannot be redirected to an
// arbitrary mail server.
let Some(imap_host) = config let Some(imap_host) = config
.imap_host .imap_host
.clone() .clone()
@@ -402,10 +391,6 @@ pub async fn login(
} }
} }
/// `GET /api/auth/session`
///
/// Returns the current user's session information. Requires authentication
/// (the `auth_guard` middleware injects `SessionState` into extensions).
pub async fn get_session(Extension(session): Extension<SessionState>) -> Response { pub async fn get_session(Extension(session): Extension<SessionState>) -> Response {
( (
StatusCode::OK, StatusCode::OK,
@@ -660,11 +645,6 @@ struct ActiveSessionResponse {
client_name: String, client_name: String,
} }
/// `GET /api/auth/sessions`
///
/// Lists all live RustBox sessions for the currently authenticated user across
/// browser sessions. Session tokens and server connection data are never
/// returned to the UI.
pub async fn list_active_sessions( pub async fn list_active_sessions(
Extension(store): Extension<Arc<SessionStore>>, Extension(store): Extension<Arc<SessionStore>>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -695,7 +675,6 @@ pub async fn list_active_sessions(
Json(serde_json::json!({ "sessions": sessions })).into_response() Json(serde_json::json!({ "sessions": sessions })).into_response()
} }
/// `DELETE /api/auth/sessions/{id}` — revoke another active session.
pub async fn revoke_active_session( pub async fn revoke_active_session(
Extension(store): Extension<Arc<SessionStore>>, Extension(store): Extension<Arc<SessionStore>>,
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
@@ -732,8 +711,6 @@ pub async fn revoke_active_session(
} }
} }
/// `POST /api/auth/sessions/revoke-others` — keep the current session and
/// revoke every other live session for the same RustBox user.
pub async fn revoke_other_sessions( pub async fn revoke_other_sessions(
Extension(store): Extension<Arc<SessionStore>>, Extension(store): Extension<Arc<SessionStore>>,
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
@@ -742,10 +719,6 @@ pub async fn revoke_other_sessions(
Json(serde_json::json!({ "status": "revoked", "count": revoked })).into_response() Json(serde_json::json!({ "status": "revoked", "count": revoked })).into_response()
} }
/// `POST /api/auth/logout`
///
/// Removes the current session from the store and clears the session cookie.
/// Requires authentication.
pub async fn logout( pub async fn logout(
Extension(store): Extension<Arc<SessionStore>>, Extension(store): Extension<Arc<SessionStore>>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
+2 -59
View File
@@ -13,20 +13,13 @@ use crate::db;
use crate::db::calendar::{CreateEvent, UpdateCalendarSettings, UpdateEvent}; use crate::db::calendar::{CreateEvent, UpdateCalendarSettings, UpdateEvent};
use crate::error::AppError; use crate::error::AppError;
// ---------------------------------------------------------------------------
// Query / request types
// ---------------------------------------------------------------------------
/// Query parameters for `GET /api/calendar/events`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ListEventsParams { pub struct ListEventsParams {
/// Start of the time range (ISO 8601).
pub start: String, pub start: String,
/// End of the time range (ISO 8601).
pub end: String, pub end: String,
} }
/// JSON body for `POST /api/calendar/meeting-templates`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateMeetingTemplateRequest { pub struct CreateMeetingTemplateRequest {
pub name: String, pub name: String,
@@ -35,7 +28,6 @@ pub struct CreateMeetingTemplateRequest {
pub icon: String, pub icon: String,
} }
/// Response for ICS import.
#[derive(Serialize)] #[derive(Serialize)]
pub struct ImportIcsResponse { pub struct ImportIcsResponse {
pub created: usize, pub created: usize,
@@ -43,10 +35,6 @@ pub struct ImportIcsResponse {
pub events: Vec<db::calendar::CalendarEvent>, pub events: Vec<db::calendar::CalendarEvent>,
} }
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
fn ics_escape(value: &str) -> String { fn ics_escape(value: &str) -> String {
value value
.replace('\\', "\\\\") .replace('\\', "\\\\")
@@ -162,9 +150,6 @@ fn append_ics_event(out: &mut String, event: &db::calendar::CalendarEvent) {
out.push_str("END:VEVENT\r\n"); out.push_str("END:VEVENT\r\n");
} }
/// `GET /api/calendar/events?start=...&end=...`
///
/// List events within a time range.
pub async fn list_events( pub async fn list_events(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -179,9 +164,6 @@ pub async fn list_events(
Ok(Json(serde_json::json!({ "events": events })).into_response()) Ok(Json(serde_json::json!({ "events": events })).into_response())
} }
/// `GET /api/calendar/events/export-ics`
///
/// Export all locally known calendar events as a standard iCalendar file.
pub async fn export_ics( pub async fn export_ics(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -192,7 +174,7 @@ pub async fn export_ics(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let mut body = String::from( let mut body = String::from(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.4//EN\r\nCALSCALE:GREGORIAN\r\n", "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.5//EN\r\nCALSCALE:GREGORIAN\r\n",
); );
for event in &events { for event in &events {
append_ics_event(&mut body, event); append_ics_event(&mut body, event);
@@ -211,9 +193,6 @@ pub async fn export_ics(
Ok(response) Ok(response)
} }
/// `GET /api/calendar/events/{id}`
///
/// Get a single event by ID.
pub async fn get_event( pub async fn get_event(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -231,9 +210,6 @@ pub async fn get_event(
} }
} }
/// `POST /api/calendar/events`
///
/// Create a new calendar event.
pub async fn create_event( pub async fn create_event(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -258,9 +234,6 @@ pub async fn create_event(
Ok((axum::http::StatusCode::CREATED, Json(event)).into_response()) Ok((axum::http::StatusCode::CREATED, Json(event)).into_response())
} }
/// `PUT /api/calendar/events/{id}`
///
/// Update an existing calendar event.
pub async fn update_event( pub async fn update_event(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -278,9 +251,6 @@ pub async fn update_event(
} }
} }
/// `DELETE /api/calendar/events/{id}`
///
/// Delete a calendar event.
pub async fn delete_event( pub async fn delete_event(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -299,9 +269,6 @@ pub async fn delete_event(
} }
} }
/// `POST /api/calendar/events/import-ics`
///
/// Import events from ICS text. Deduplicates by source_uid.
pub async fn import_ics( pub async fn import_ics(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -321,7 +288,6 @@ pub async fn import_ics(
let mut skipped = 0usize; let mut skipped = 0usize;
for event_data in parsed { for event_data in parsed {
// Deduplicate by source_uid
if let Some(ref uid) = event_data.source_uid if let Some(ref uid) = event_data.source_uid
&& let Ok(Some(_)) = db::calendar::find_event_by_source_uid(&conn, uid) && let Ok(Some(_)) = db::calendar::find_event_by_source_uid(&conn, uid)
{ {
@@ -346,13 +312,6 @@ pub async fn import_ics(
.into_response()) .into_response())
} }
// ---------------------------------------------------------------------------
// Settings handlers
// ---------------------------------------------------------------------------
/// `GET /api/calendar/settings`
///
/// Get calendar settings.
pub async fn get_calendar_settings( pub async fn get_calendar_settings(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -366,9 +325,6 @@ pub async fn get_calendar_settings(
Ok(Json(settings).into_response()) Ok(Json(settings).into_response())
} }
/// `PUT /api/calendar/settings`
///
/// Update calendar settings.
pub async fn update_calendar_settings( pub async fn update_calendar_settings(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -383,13 +339,6 @@ pub async fn update_calendar_settings(
Ok(Json(settings).into_response()) Ok(Json(settings).into_response())
} }
// ---------------------------------------------------------------------------
// Meeting template handlers
// ---------------------------------------------------------------------------
/// `GET /api/calendar/meeting-templates`
///
/// List all meeting templates.
pub async fn list_meeting_templates( pub async fn list_meeting_templates(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -403,9 +352,6 @@ pub async fn list_meeting_templates(
Ok(Json(serde_json::json!({ "templates": templates })).into_response()) Ok(Json(serde_json::json!({ "templates": templates })).into_response())
} }
/// `POST /api/calendar/meeting-templates`
///
/// Create a new meeting template.
pub async fn create_meeting_template( pub async fn create_meeting_template(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -428,9 +374,6 @@ pub async fn create_meeting_template(
Ok((axum::http::StatusCode::CREATED, Json(template)).into_response()) Ok((axum::http::StatusCode::CREATED, Json(template)).into_response())
} }
/// `DELETE /api/calendar/meeting-templates/{id}`
///
/// Delete a meeting template.
pub async fn delete_meeting_template( pub async fn delete_meeting_template(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
-16
View File
@@ -13,10 +13,6 @@ use crate::db::contact_groups::ContactGroup;
use crate::db::contacts::Contact; use crate::db::contacts::Contact;
use crate::error::AppError; use crate::error::AppError;
// ---------------------------------------------------------------------------
// Request / response types
// ---------------------------------------------------------------------------
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateGroupBody { pub struct CreateGroupBody {
pub name: String, pub name: String,
@@ -37,11 +33,6 @@ pub struct GroupMembersResponse {
pub members: Vec<Contact>, pub members: Vec<Contact>,
} }
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `GET /api/contact-groups`
pub async fn list_groups_handler( pub async fn list_groups_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -55,7 +46,6 @@ pub async fn list_groups_handler(
Ok(Json(ListGroupsResponse { groups }).into_response()) Ok(Json(ListGroupsResponse { groups }).into_response())
} }
/// `POST /api/contact-groups`
pub async fn create_group_handler( pub async fn create_group_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -76,7 +66,6 @@ pub async fn create_group_handler(
Ok(Json(serde_json::json!({ "id": id, "name": name })).into_response()) Ok(Json(serde_json::json!({ "id": id, "name": name })).into_response())
} }
/// `PUT /api/contact-groups/{id}`
pub async fn update_group_handler( pub async fn update_group_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -101,7 +90,6 @@ pub async fn update_group_handler(
} }
} }
/// `DELETE /api/contact-groups/{id}`
pub async fn delete_group_handler( pub async fn delete_group_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -120,7 +108,6 @@ pub async fn delete_group_handler(
} }
} }
/// `GET /api/contact-groups/{id}/members`
pub async fn list_members_handler( pub async fn list_members_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -135,7 +122,6 @@ pub async fn list_members_handler(
Ok(Json(GroupMembersResponse { members }).into_response()) Ok(Json(GroupMembersResponse { members }).into_response())
} }
/// `GET /api/contacts/{contact_id}/groups`
pub async fn list_contact_groups_handler( pub async fn list_contact_groups_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -148,7 +134,6 @@ pub async fn list_contact_groups_handler(
Ok(Json(ListGroupsResponse { groups }).into_response()) Ok(Json(ListGroupsResponse { groups }).into_response())
} }
/// `POST /api/contact-groups/{id}/members`
pub async fn add_member_handler( pub async fn add_member_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -164,7 +149,6 @@ pub async fn add_member_handler(
Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
} }
/// `DELETE /api/contact-groups/{id}/members/{contact_id}`
pub async fn remove_member_handler( pub async fn remove_member_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
+45 -118
View File
@@ -12,34 +12,25 @@ use crate::db;
use crate::db::contacts::Contact; use crate::db::contacts::Contact;
use crate::error::AppError; use crate::error::AppError;
// ---------------------------------------------------------------------------
// Query / request types
// ---------------------------------------------------------------------------
/// Query parameters for `GET /api/contacts`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ListContactsParams { pub struct ListContactsParams {
/// Optional search query to filter by name or email.
pub q: Option<String>, pub q: Option<String>,
/// Maximum number of results (default 50).
#[serde(default = "default_limit")] #[serde(default = "default_limit")]
pub limit: u32, pub limit: u32,
/// Offset for pagination (default 0).
#[serde(default)] #[serde(default)]
pub offset: u32, pub offset: u32,
} }
/// Query parameters for `GET /api/contacts/autocomplete`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct AutocompleteParams { pub struct AutocompleteParams {
/// Search query (required for autocomplete).
pub q: Option<String>, pub q: Option<String>,
/// Maximum number of suggestions (default 10).
#[serde(default = "default_autocomplete_limit")] #[serde(default = "default_autocomplete_limit")]
pub limit: u32, pub limit: u32,
} }
/// JSON body for `POST /api/contacts`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateContactBody { pub struct CreateContactBody {
pub id: Option<String>, pub id: Option<String>,
@@ -91,18 +82,12 @@ fn default_source() -> String {
"manual".to_string() "manual".to_string()
} }
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
/// Response envelope for `GET /api/contacts`.
#[derive(Serialize)] #[derive(Serialize)]
pub struct ListContactsResponse { pub struct ListContactsResponse {
pub contacts: Vec<Contact>, pub contacts: Vec<Contact>,
pub total_count: usize, pub total_count: usize,
} }
/// A single autocomplete suggestion.
#[derive(Serialize)] #[derive(Serialize)]
pub struct AutocompleteSuggestion { pub struct AutocompleteSuggestion {
pub email: String, pub email: String,
@@ -110,16 +95,11 @@ pub struct AutocompleteSuggestion {
pub source: Option<String>, pub source: Option<String>,
} }
/// Response envelope for `GET /api/contacts/autocomplete`.
#[derive(Serialize)] #[derive(Serialize)]
pub struct AutocompleteResponse { pub struct AutocompleteResponse {
pub suggestions: Vec<AutocompleteSuggestion>, pub suggestions: Vec<AutocompleteSuggestion>,
} }
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DuplicateContactGroup { pub struct DuplicateContactGroup {
@@ -161,10 +141,6 @@ fn normalize_contact_values(values: Vec<String>, primary: &str) -> Vec<String> {
result result
} }
/// `GET /api/contacts?q=&limit=50&offset=0`
///
/// Lists contacts with optional search. If `q` is provided, uses
/// `search_contacts`; otherwise uses `list_contacts`.
pub async fn list_contacts_handler( pub async fn list_contacts_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -195,9 +171,6 @@ pub async fn list_contacts_handler(
.into_response()) .into_response())
} }
/// `POST /api/contacts`
///
/// Creates or updates a contact. Generates a UUID for `id` if not provided.
pub async fn create_contact_handler( pub async fn create_contact_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -261,10 +234,6 @@ pub async fn create_contact_handler(
Ok(Json(saved).into_response()) Ok(Json(saved).into_response())
} }
/// `GET /api/contacts/duplicates`
///
/// Detect duplicate contacts by primary or alternate e-mail addresses.
/// Only groups with at least two distinct contacts are returned.
pub async fn duplicate_contacts_handler( pub async fn duplicate_contacts_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -313,10 +282,6 @@ pub async fn duplicate_contacts_handler(
Ok(Json(serde_json::json!({ "duplicates": groups })).into_response()) Ok(Json(serde_json::json!({ "duplicates": groups })).into_response())
} }
/// `POST /api/contacts/merge`
///
/// Merge source into target, preserving target values where both are present.
/// Alternate addresses, groups and the source photo are retained when useful.
pub async fn merge_contacts_handler( pub async fn merge_contacts_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -427,10 +392,6 @@ pub async fn merge_contacts_handler(
Ok(Json(merged).into_response()) Ok(Json(merged).into_response())
} }
/// `GET /api/contacts/autocomplete?q=al&limit=10`
///
/// Fast autocomplete endpoint. Returns matching contacts as lightweight
/// suggestions with only email, name, and source.
pub async fn autocomplete_handler( pub async fn autocomplete_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -455,11 +416,9 @@ pub async fn autocomplete_handler(
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Search contacts first
let contacts = db::contacts::search_contacts(&conn, query, params.limit) let contacts = db::contacts::search_contacts(&conn, query, params.limit)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Build suggestions from contacts with deduplication by email (lowercase)
let mut seen_emails = std::collections::HashSet::new(); let mut seen_emails = std::collections::HashSet::new();
let mut suggestions: Vec<AutocompleteSuggestion> = Vec::new(); let mut suggestions: Vec<AutocompleteSuggestion> = Vec::new();
@@ -484,7 +443,6 @@ pub async fn autocomplete_handler(
} }
} }
// If we haven't hit the limit, also search known addresses
if suggestions.len() < params.limit as usize { if suggestions.len() < params.limit as usize {
let remaining = params.limit as usize - suggestions.len(); let remaining = params.limit as usize - suggestions.len();
#[allow(dead_code)] #[allow(dead_code)]
@@ -503,16 +461,11 @@ pub async fn autocomplete_handler(
} }
} }
// Truncate to limit in case we went over
suggestions.truncate(params.limit as usize); suggestions.truncate(params.limit as usize);
Ok(Json(AutocompleteResponse { suggestions }).into_response()) Ok(Json(AutocompleteResponse { suggestions }).into_response())
} }
/// `GET /api/contacts/autocomplete/all`
///
/// Returns all contacts and known addresses for client-side autocomplete.
/// Used for prefetching to enable instant fuzzy search without network latency.
pub async fn autocomplete_all_handler( pub async fn autocomplete_all_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -564,9 +517,6 @@ pub async fn autocomplete_all_handler(
Ok(Json(AutocompleteResponse { suggestions }).into_response()) Ok(Json(AutocompleteResponse { suggestions }).into_response())
} }
/// `GET /api/contacts/:id`
///
/// Returns a single contact by id. Returns 404 if not found.
pub async fn get_contact_handler( pub async fn get_contact_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -584,9 +534,6 @@ pub async fn get_contact_handler(
} }
} }
/// `DELETE /api/contacts/:id`
///
/// Deletes a contact by id. Returns 404 if the contact does not exist.
pub async fn delete_contact_handler( pub async fn delete_contact_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -619,7 +566,6 @@ fn contact_photo_type(data: &[u8]) -> Option<&'static str> {
} }
} }
/// Upload or replace a contact photo. Only PNG, JPEG and WebP are accepted.
pub async fn upload_contact_photo_handler( pub async fn upload_contact_photo_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -664,7 +610,6 @@ pub async fn upload_contact_photo_handler(
.into_response()) .into_response())
} }
/// Return a contact photo without exposing its storage representation.
pub async fn get_contact_photo_handler( pub async fn get_contact_photo_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -684,7 +629,6 @@ pub async fn get_contact_photo_handler(
.map_err(|e| AppError::InternalError(format!("Failed to build photo response: {e}"))) .map_err(|e| AppError::InternalError(format!("Failed to build photo response: {e}")))
} }
/// Remove a contact photo while keeping the contact itself.
pub async fn delete_contact_photo_handler( pub async fn delete_contact_photo_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -697,11 +641,6 @@ pub async fn delete_contact_photo_handler(
Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) Ok(Json(serde_json::json!({ "status": "deleted" })).into_response())
} }
// ---------------------------------------------------------------------------
// vCard import/export
// ---------------------------------------------------------------------------
/// Escape special characters in a vCard field value.
fn vcard_escape(s: &str) -> String { fn vcard_escape(s: &str) -> String {
s.replace('\\', "\\\\") s.replace('\\', "\\\\")
.replace(',', "\\,") .replace(',', "\\,")
@@ -709,7 +648,6 @@ fn vcard_escape(s: &str) -> String {
.replace('\n', "\\n") .replace('\n', "\\n")
} }
/// Serialize a Contact to vCard 3.0 format.
fn contact_to_vcard(c: &Contact) -> String { fn contact_to_vcard(c: &Contact) -> String {
let mut lines = Vec::new(); let mut lines = Vec::new();
lines.push("BEGIN:VCARD".to_string()); lines.push("BEGIN:VCARD".to_string());
@@ -761,7 +699,6 @@ fn contact_to_vcard(c: &Contact) -> String {
lines.join("\r\n") lines.join("\r\n")
} }
/// A parsed vCard entry with optional fields.
struct ParsedVCard { struct ParsedVCard {
name: String, name: String,
email: String, email: String,
@@ -777,11 +714,9 @@ struct ParsedVCard {
birthday: String, birthday: String,
} }
/// Parse vCard data (potentially multiple entries) from a string.
fn parse_vcards(data: &str) -> Vec<ParsedVCard> { fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
let mut results = Vec::new(); let mut results = Vec::new();
// Unfold continuation lines (lines starting with space or tab are continuations).
let unfolded = data let unfolded = data
.replace("\r\n ", "") .replace("\r\n ", "")
.replace("\r\n\t", "") .replace("\r\n\t", "")
@@ -847,11 +782,10 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
continue; continue;
} }
// Extract field name and value (handle parameters like EMAIL;TYPE=WORK:...)
let (field_name, value) = if let Some(colon_pos) = line.find(':') { let (field_name, value) = if let Some(colon_pos) = line.find(':') {
let raw_key = &line[..colon_pos]; let raw_key = &line[..colon_pos];
let value = &line[colon_pos + 1..]; let value = &line[colon_pos + 1..];
// Strip parameters (e.g., EMAIL;TYPE=WORK -> EMAIL)
let field = if let Some(semi_pos) = raw_key.find(';') { let field = if let Some(semi_pos) = raw_key.find(';') {
&raw_key[..semi_pos] &raw_key[..semi_pos]
} else { } else {
@@ -862,7 +796,6 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
continue; continue;
}; };
// Unescape vCard values
let value = value let value = value
.replace("\\n", "\n") .replace("\\n", "\n")
.replace("\\,", ",") .replace("\\,", ",")
@@ -870,21 +803,18 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
.replace("\\\\", "\\"); .replace("\\\\", "\\");
match field_name.as_str() { match field_name.as_str() {
"FN" "FN" if name.is_empty() => {
if name.is_empty() => { name = value;
name = value; }
} "N" if name.is_empty() => {
"N" let parts: Vec<&str> = value.splitn(5, ';').collect();
// N:Last;First;Middle;Prefix;Suffix — use FN if available, else reconstruct let last = parts.first().unwrap_or(&"").trim();
if name.is_empty() => { let first = parts.get(1).unwrap_or(&"").trim();
let parts: Vec<&str> = value.splitn(5, ';').collect(); let combined = format!("{first} {last}").trim().to_string();
let last = parts.first().unwrap_or(&"").trim(); if !combined.is_empty() {
let first = parts.get(1).unwrap_or(&"").trim(); name = combined;
let combined = format!("{first} {last}").trim().to_string();
if !combined.is_empty() {
name = combined;
}
} }
}
"EMAIL" => { "EMAIL" => {
if email.is_empty() { if email.is_empty() {
email = value; email = value;
@@ -892,27 +822,40 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
emails.push(value); emails.push(value);
} }
} }
"ORG" "ORG" if company.is_empty() => {
if company.is_empty() => { company = value;
company = value; }
} "NOTE" if notes.is_empty() => {
"NOTE" notes = value;
if notes.is_empty() => { }
notes = value;
}
"TEL" => { "TEL" => {
if phone.is_empty() { phone = value; } if phone.is_empty() {
else if !phones.iter().any(|item| item == &value) { phones.push(value); } phone = value;
} else if !phones.iter().any(|item| item == &value) {
phones.push(value);
}
}
"TITLE" if job_title.is_empty() => {
job_title = value;
} }
"TITLE" if job_title.is_empty() => { job_title = value; }
"ADR" => { "ADR" => {
let parts: Vec<&str> = value.split(';').filter(|part| !part.trim().is_empty()).collect(); let parts: Vec<&str> = value
.split(';')
.filter(|part| !part.trim().is_empty())
.collect();
let parsed = parts.join(", "); let parsed = parts.join(", ");
if address.is_empty() { address = parsed; } if address.is_empty() {
else if !parsed.is_empty() && !addresses.iter().any(|item| item == &parsed) { addresses.push(parsed); } address = parsed;
} else if !parsed.is_empty() && !addresses.iter().any(|item| item == &parsed) {
addresses.push(parsed);
}
}
"URL" if website.is_empty() => {
website = value;
}
"BDAY" if birthday.is_empty() => {
birthday = value;
} }
"URL" if website.is_empty() => { website = value; }
"BDAY" if birthday.is_empty() => { birthday = value; }
_ => {} _ => {}
} }
} }
@@ -920,9 +863,6 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
results results
} }
/// `GET /api/contacts/export`
///
/// Export all contacts as a .vcf file download.
pub async fn export_contacts_handler( pub async fn export_contacts_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -949,9 +889,6 @@ pub async fn export_contacts_handler(
.unwrap()) .unwrap())
} }
/// `GET /api/contacts/{id}/export`
///
/// Export a single contact as a .vcf file download.
pub async fn export_single_contact_handler( pub async fn export_single_contact_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -966,11 +903,7 @@ pub async fn export_single_contact_handler(
match contact { match contact {
Some(c) => { Some(c) => {
let vcf = contact_to_vcard(&c); let vcf = contact_to_vcard(&c);
// Strip ASCII control characters (U+0000-U+001F and U+007F) from
// the contact name before using it in the Content-Disposition
// filename. HeaderValue::from_str routes through is_valid
// (accepts 0x09, 0x20-0x7E, 0x80-0xFF; rejects 0x00-0x08,
// 0x0A-0x1F, 0x7F), so non-ASCII UTF-8 bytes are fine.
let safe_name: String = c.name.chars().filter(|c| !c.is_ascii_control()).collect(); let safe_name: String = c.name.chars().filter(|c| !c.is_ascii_control()).collect();
let filename = if safe_name.is_empty() { let filename = if safe_name.is_empty() {
"contact.vcf".to_string() "contact.vcf".to_string()
@@ -991,7 +924,6 @@ pub async fn export_single_contact_handler(
} }
} }
/// Import response with counts.
#[derive(Serialize)] #[derive(Serialize)]
pub struct ImportResponse { pub struct ImportResponse {
pub created: usize, pub created: usize,
@@ -999,15 +931,11 @@ pub struct ImportResponse {
pub skipped: usize, pub skipped: usize,
} }
/// `POST /api/contacts/import`
///
/// Import contacts from an uploaded .vcf file (multipart form data).
pub async fn import_contacts_handler( pub async fn import_contacts_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Read the uploaded file content
let mut vcf_content = String::new(); let mut vcf_content = String::new();
if let Some(field) = multipart if let Some(field) = multipart
.next_field() .next_field()
@@ -1050,7 +978,6 @@ pub async fn import_contacts_handler(
match existing { match existing {
Some(mut c) => { Some(mut c) => {
// Merge only empty fields (don't overwrite manual edits).
let mut changed = false; let mut changed = false;
if c.name.is_empty() && !entry.name.is_empty() { if c.name.is_empty() && !entry.name.is_empty() {
c.name = entry.name.clone(); c.name = entry.name.clone();
-24
View File
@@ -70,10 +70,6 @@ pub struct SearchRebuildResponse {
source_messages: usize, source_messages: usize,
} }
/// `GET /api/diagnostics/imap`
///
/// Returns protocol capabilities for the authenticated account. Host names and
/// credentials are deliberately not exposed to the browser.
pub async fn imap_diagnostics( pub async fn imap_diagnostics(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
@@ -128,10 +124,6 @@ pub async fn imap_diagnostics(
.into_response()) .into_response())
} }
/// `GET /api/diagnostics/smtp`
///
/// Performs an authenticated SMTP connection check using NOOP. Server host
/// names and credentials are never returned to the browser.
pub async fn smtp_diagnostics( pub async fn smtp_diagnostics(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(smtp_client): Extension<Arc<dyn SmtpClient>>, Extension(smtp_client): Extension<Arc<dyn SmtpClient>>,
@@ -159,10 +151,6 @@ pub async fn smtp_diagnostics(
.into_response()) .into_response())
} }
/// `POST /api/diagnostics/imap/reconnect`
///
/// Drops reusable request-path IMAP connections for the current account and
/// verifies a fresh authenticated connection by reading capabilities.
pub async fn reconnect_imap( pub async fn reconnect_imap(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
@@ -213,10 +201,6 @@ fn index_sources_to_messages(
.collect() .collect()
} }
/// `GET /api/diagnostics/sync`
///
/// Reports only local cache synchronization metadata. No server names, message
/// content, credentials or folder names are exposed.
pub async fn sync_diagnostics( pub async fn sync_diagnostics(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -265,10 +249,6 @@ pub async fn sync_diagnostics(
Ok(Json(stats).into_response()) Ok(Json(stats).into_response())
} }
/// `GET /api/diagnostics/search`
///
/// Reports the current Tantivy document count and the number of cached SQLite
/// messages available for a rebuild. This endpoint does not expose message text.
pub async fn search_diagnostics( pub async fn search_diagnostics(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -311,10 +291,6 @@ pub async fn search_diagnostics(
Ok(Json(stats).into_response()) Ok(Json(stats).into_response())
} }
/// `POST /api/diagnostics/search/rebuild`
///
/// Recreates the current user's Tantivy index exclusively from the local SQLite
/// cache. Spam/Junk/Trash folders remain excluded by the normal index policy.
pub async fn rebuild_search_index( pub async fn rebuild_search_index(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
-2
View File
@@ -17,7 +17,6 @@ fn map_update_error(message: String) -> AppError {
} }
} }
/// `GET /api/settings/display`
pub async fn get_display_preferences( pub async fn get_display_preferences(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -30,7 +29,6 @@ pub async fn get_display_preferences(
Ok(Json(prefs).into_response()) Ok(Json(prefs).into_response())
} }
/// `PUT /api/settings/display`
pub async fn update_display_preferences( pub async fn update_display_preferences(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
+1 -21
View File
@@ -13,10 +13,6 @@ use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
use crate::smtp::client::AttachmentData; use crate::smtp::client::AttachmentData;
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpsertDraftRequest { pub struct UpsertDraftRequest {
pub id: String, pub id: String,
@@ -47,8 +43,7 @@ pub struct UpsertDraftRequest {
pub pgp_attach_public_key: bool, pub pgp_attach_public_key: bool,
#[serde(default)] #[serde(default)]
pub pgp_attach_recipient_keys: bool, pub pgp_attach_recipient_keys: bool,
/// Sync this saved snapshot to the IMAP Drafts folder. Autosave requests
/// keep this false so typing does not append a new remote draft every time.
#[serde(default)] #[serde(default)]
pub sync_imap: bool, pub sync_imap: bool,
} }
@@ -116,11 +111,6 @@ struct DeleteResponse {
status: String, status: String,
} }
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `POST /api/drafts` — Create or update a draft.
pub async fn upsert_draft_handler( pub async fn upsert_draft_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -174,10 +164,6 @@ pub async fn upsert_draft_handler(
) )
.map_err(AppError::InternalError)?; .map_err(AppError::InternalError)?;
// Best-effort: sync deliberate snapshots to IMAP Drafts. Autosaves remain
// local so typing never produces a remote message per keystroke. The
// server-assigned UID is resolved from UIDNEXT + an application-owned
// identity header before an older snapshot is removed.
if req.sync_imap && !session.imap_host.trim().is_empty() { if req.sync_imap && !session.imap_host.trim().is_empty() {
let imap_creds = session_imap_credentials(&session); let imap_creds = session_imap_credentials(&session);
let drafts_folder = db::folders::find_folder_by_attribute(&conn, "\\Drafts") let drafts_folder = db::folders::find_folder_by_attribute(&conn, "\\Drafts")
@@ -353,7 +339,6 @@ fn load_snapshot_attachments(
.collect() .collect()
} }
/// Build a minimal RFC822 message from draft fields for IMAP APPEND.
fn build_draft_rfc822( fn build_draft_rfc822(
req: &UpsertDraftRequest, req: &UpsertDraftRequest,
from_email: &str, from_email: &str,
@@ -377,7 +362,6 @@ fn build_draft_rfc822(
draft_id, draft_id,
)); ));
// Parse comma-separated address lists.
for addr in req.to.split(',').map(str::trim).filter(|s| !s.is_empty()) { for addr in req.to.split(',').map(str::trim).filter(|s| !s.is_empty()) {
if let Ok(mailbox) = addr.parse::<Mailbox>() { if let Ok(mailbox) = addr.parse::<Mailbox>() {
builder = builder.to(mailbox); builder = builder.to(mailbox);
@@ -473,7 +457,6 @@ fn build_draft_rfc822(
Ok(email.formatted()) Ok(email.formatted())
} }
/// `GET /api/drafts` — List all drafts.
pub async fn list_drafts_handler( pub async fn list_drafts_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -497,7 +480,6 @@ pub async fn list_drafts_handler(
Ok(Json(serde_json::json!({ "drafts": items })).into_response()) Ok(Json(serde_json::json!({ "drafts": items })).into_response())
} }
/// `GET /api/drafts/{id}` — Get a single draft with its attachments.
pub async fn get_draft_handler( pub async fn get_draft_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -549,7 +531,6 @@ pub async fn get_draft_handler(
.into_response()) .into_response())
} }
/// `DELETE /api/drafts/{id}` — Delete a draft and its attachments.
pub async fn delete_draft_handler( pub async fn delete_draft_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -575,7 +556,6 @@ pub async fn delete_draft_handler(
return Err(AppError::NotFound("Draft not found".to_string())); return Err(AppError::NotFound("Draft not found".to_string()));
} }
// Clean up attachment files from disk.
let att_dir = Path::new(&config.data_dir) let att_dir = Path::new(&config.data_dir)
.join(&session.user_hash) .join(&session.user_hash)
.join("attachments") .join("attachments")
-30
View File
@@ -12,10 +12,6 @@ use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
use crate::search::engine::SearchEngine; use crate::search::engine::SearchEngine;
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateFolderRequest { pub struct CreateFolderRequest {
pub name: String, pub name: String,
@@ -36,19 +32,12 @@ struct MessageResponse {
status: &'static str, status: &'static str,
} }
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Folder names that must not be renamed or deleted.
const SYSTEM_FOLDERS: &[&str] = &["INBOX", "Sent", "Drafts", "Trash", "Junk", "Spam"]; const SYSTEM_FOLDERS: &[&str] = &["INBOX", "Sent", "Drafts", "Trash", "Junk", "Spam"];
/// Returns `true` if the folder is a system folder (case-insensitive).
fn is_system_folder(name: &str) -> bool { fn is_system_folder(name: &str) -> bool {
SYSTEM_FOLDERS.iter().any(|s| s.eq_ignore_ascii_case(name)) SYSTEM_FOLDERS.iter().any(|s| s.eq_ignore_ascii_case(name))
} }
/// Validates a folder name. Returns `Ok(())` if valid, or an `AppError::BadRequest`.
fn validate_folder_name(name: &str) -> Result<(), AppError> { fn validate_folder_name(name: &str) -> Result<(), AppError> {
if name.trim().is_empty() { if name.trim().is_empty() {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
@@ -73,7 +62,6 @@ fn validate_folder_name(name: &str) -> Result<(), AppError> {
Ok(()) Ok(())
} }
/// Build IMAP credentials from the session and app configuration.
fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCredentials, AppError> { fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCredentials, AppError> {
Ok(ImapCredentials { Ok(ImapCredentials {
host: session.imap_host.clone(), host: session.imap_host.clone(),
@@ -85,11 +73,6 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCreden
}) })
} }
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `POST /api/folders` -- create a new IMAP folder.
pub async fn create_folder( pub async fn create_folder(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -100,13 +83,11 @@ pub async fn create_folder(
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
// Create the folder on the IMAP server (also subscribes).
imap_client imap_client
.create_folder(&creds, &body.name) .create_folder(&creds, &body.name)
.await .await
.map_err(|e| AppError::InternalError(format!("IMAP create_folder failed: {e}")))?; .map_err(|e| AppError::InternalError(format!("IMAP create_folder failed: {e}")))?;
// Cache the new folder locally.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -116,7 +97,6 @@ pub async fn create_folder(
Ok(Json(MessageResponse { status: "created" }).into_response()) Ok(Json(MessageResponse { status: "created" }).into_response())
} }
/// `PATCH /api/folders/:name` -- rename an existing folder.
pub async fn rename_folder( pub async fn rename_folder(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -139,7 +119,6 @@ pub async fn rename_folder(
.await .await
.map_err(|e| AppError::InternalError(format!("IMAP rename_folder failed: {e}")))?; .map_err(|e| AppError::InternalError(format!("IMAP rename_folder failed: {e}")))?;
// Update the local cache.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -151,7 +130,6 @@ pub async fn rename_folder(
Ok(Json(MessageResponse { status: "renamed" }).into_response()) Ok(Json(MessageResponse { status: "renamed" }).into_response())
} }
/// `DELETE /api/folders/:name` -- delete a folder.
pub async fn delete_folder( pub async fn delete_folder(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -171,7 +149,6 @@ pub async fn delete_folder(
.await .await
.map_err(|e| AppError::InternalError(format!("IMAP delete_folder failed: {e}")))?; .map_err(|e| AppError::InternalError(format!("IMAP delete_folder failed: {e}")))?;
// Remove from local cache.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -183,7 +160,6 @@ pub async fn delete_folder(
Ok(Json(MessageResponse { status: "deleted" }).into_response()) Ok(Json(MessageResponse { status: "deleted" }).into_response())
} }
/// `PATCH /api/folders/:name/subscribe` -- toggle folder subscription.
pub async fn subscribe_folder( pub async fn subscribe_folder(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -208,7 +184,6 @@ pub async fn subscribe_folder(
.into_response()) .into_response())
} }
/// `POST /api/folders/:name/mark-all-read` -- add \Seen to every message.
pub async fn mark_all_read( pub async fn mark_all_read(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -237,7 +212,6 @@ pub async fn mark_all_read(
Ok(Json(MessageResponse { status: "read" }).into_response()) Ok(Json(MessageResponse { status: "read" }).into_response())
} }
/// `POST /api/folders/:name/empty` -- permanently remove every message.
pub async fn empty_folder( pub async fn empty_folder(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -281,10 +255,6 @@ pub async fn empty_folder(
Ok(Json(MessageResponse { status: "emptied" }).into_response()) Ok(Json(MessageResponse { status: "emptied" }).into_response())
} }
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+1 -23
View File
@@ -10,13 +10,11 @@ use crate::db;
use crate::error::AppError; use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
/// Response envelope for `GET /api/folders`.
#[derive(Serialize)] #[derive(Serialize)]
struct FoldersResponse { struct FoldersResponse {
folders: Vec<FolderEntry>, folders: Vec<FolderEntry>,
} }
/// A single folder in the response.
#[derive(Serialize)] #[derive(Serialize)]
struct FolderEntry { struct FolderEntry {
name: String, name: String,
@@ -27,24 +25,16 @@ struct FolderEntry {
unread_count: u32, unread_count: u32,
} }
/// How many seconds the folder list cache is considered fresh.
const FOLDER_LIST_TTL_SECS: u32 = 30; const FOLDER_LIST_TTL_SECS: u32 = 30;
/// `GET /api/folders`
///
/// Lists all IMAP folders for the authenticated user, syncing the result
/// into the per-user SQLite cache. If the cache was refreshed within
/// `FOLDER_LIST_TTL_SECS` seconds, IMAP is skipped entirely.
pub async fn list_folders( pub async fn list_folders(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Open the per-user database.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// If the folder cache was updated recently, skip the IMAP round-trip.
let cache_fresh = db::folders::is_folder_cache_fresh(&conn, FOLDER_LIST_TTL_SECS) let cache_fresh = db::folders::is_folder_cache_fresh(&conn, FOLDER_LIST_TTL_SECS)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -53,20 +43,16 @@ pub async fn list_folders(
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
tls: session.imap_tls, tls: session.imap_tls,
tls_verify: session.imap_tls_verify, tls_verify: session.imap_tls_verify,
email: session.email.clone(), email: session.email.clone(),
password: session.password.clone(), password: session.password.clone(),
}; };
// Fetch folders from IMAP server.
let imap_folders = imap_client let imap_folders = imap_client
.list_folders(&creds) .list_folders(&creds)
.await .await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
// Sync each folder into SQLite cache.
// Use INSERT OR IGNORE to create new folders without triggering CASCADE on
// existing ones, then UPDATE the metadata fields separately.
for folder in &imap_folders { for folder in &imap_folders {
let flags_csv = folder.attributes.join(","); let flags_csv = folder.attributes.join(",");
db::folders::insert_folder_if_new( db::folders::insert_folder_if_new(
@@ -78,20 +64,14 @@ pub async fn list_folders(
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
} }
// Remove stale folders that no longer exist on the server.
let current_names: Vec<String> = imap_folders.iter().map(|f| f.name.clone()).collect(); let current_names: Vec<String> = imap_folders.iter().map(|f| f.name.clone()).collect();
db::folders::remove_stale_folders(&conn, &current_names) db::folders::remove_stale_folders(&conn, &current_names)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Touch updated_at on all folders so the cache TTL resets.
db::folders::touch_all_folders(&conn) db::folders::touch_all_folders(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
} }
// Refresh unread counts from cached messages — but skip folders whose
// messages cache has been invalidated (messages_updated_at IS NULL).
// Those folders have a manually adjusted unread_count (via adjust_unread_count)
// that should be preserved until the folder's messages are resynced from IMAP.
let all_folders = db::folders::get_all_folders(&conn) let all_folders = db::folders::get_all_folders(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
for f in &all_folders { for f in &all_folders {
@@ -103,11 +83,9 @@ pub async fn list_folders(
} }
} }
// Read back from cache to get the refreshed counts.
let cached = db::folders::get_all_folders(&conn) let cached = db::folders::get_all_folders(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Build response from cached data (includes any stored counts).
let folders: Vec<FolderEntry> = cached let folders: Vec<FolderEntry> = cached
.into_iter() .into_iter()
.map(|f| { .map(|f| {
-9
View File
@@ -13,21 +13,12 @@ pub struct HealthResponse {
pub status: String, pub status: String,
} }
/// Handler for `GET /api/health`.
///
/// Liveness only: if the process can answer HTTP, it is alive.
pub async fn health_check() -> Json<HealthResponse> { pub async fn health_check() -> Json<HealthResponse> {
Json(HealthResponse { Json(HealthResponse {
status: "ok".to_string(), status: "ok".to_string(),
}) })
} }
/// Handler for `GET /api/ready`.
///
/// Readiness additionally verifies that RustBox can create/access its local
/// data directory. External IMAP/SMTP servers are intentionally not probed:
/// they are account-specific and a remote outage must not make the whole
/// RustBox process unready for every account.
pub async fn readiness_check(Extension(config): Extension<Arc<AppConfig>>) -> Response { pub async fn readiness_check(Extension(config): Extension<Arc<AppConfig>>) -> Response {
let ready = fs::create_dir_all(&config.data_dir).is_ok() let ready = fs::create_dir_all(&config.data_dir).is_ok()
&& fs::metadata(&config.data_dir).is_ok_and(|metadata| metadata.is_dir()); && fs::metadata(&config.data_dir).is_ok_and(|metadata| metadata.is_dir());
-5
View File
@@ -10,7 +10,6 @@ use crate::db;
use crate::db::identities::{CreateIdentity, UpdateIdentity}; use crate::db::identities::{CreateIdentity, UpdateIdentity};
use crate::error::AppError; use crate::error::AppError;
/// `GET /api/identities` — list all sender identities.
pub async fn list_identities_handler( pub async fn list_identities_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -23,7 +22,6 @@ pub async fn list_identities_handler(
Ok(Json(serde_json::json!({ "identities": identities })).into_response()) Ok(Json(serde_json::json!({ "identities": identities })).into_response())
} }
/// `GET /api/identities/:id` — get a single identity.
pub async fn get_identity_handler( pub async fn get_identity_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -40,7 +38,6 @@ pub async fn get_identity_handler(
} }
} }
/// `POST /api/identities` — create a new identity.
pub async fn create_identity_handler( pub async fn create_identity_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -60,7 +57,6 @@ pub async fn create_identity_handler(
Ok((axum::http::StatusCode::CREATED, Json(identity)).into_response()) Ok((axum::http::StatusCode::CREATED, Json(identity)).into_response())
} }
/// `PUT /api/identities/:id` — update an identity.
pub async fn update_identity_handler( pub async fn update_identity_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -79,7 +75,6 @@ pub async fn update_identity_handler(
} }
} }
/// `DELETE /api/identities/:id` — delete an identity.
pub async fn delete_identity_handler( pub async fn delete_identity_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
+7 -151
View File
@@ -19,10 +19,6 @@ use crate::imap::client::{ImapClient, ImapCredentials};
use crate::realtime::events::{EventBus, MailEvent}; use crate::realtime::events::{EventBus, MailEvent};
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex}; use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
// ---------------------------------------------------------------------------
// Helper: build IMAP credentials from session + config
// ---------------------------------------------------------------------------
fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCredentials, AppError> { fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCredentials, AppError> {
Ok(ImapCredentials { Ok(ImapCredentials {
host: session.imap_host.clone(), host: session.imap_host.clone(),
@@ -34,26 +30,11 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCreden
}) })
} }
// ---------------------------------------------------------------------------
// Helper: validate IMAP flags from client input
// ---------------------------------------------------------------------------
/// Returns `true` if `flag` is a syntactically valid IMAP flag.
///
/// Accepts RFC 3501 system flags (`\Seen`, `\Answered`, `\Flagged`,
/// `\Deleted`, `\Draft`, `\Recent`) and keyword atoms (`Junk`,
/// `$Forwarded`, `$MDNSent`, etc.).
///
/// This is a restricted safe subset of the RFC 3501 §9 atom grammar:
/// an optional leading `\` followed by `[A-Za-z0-9$_\-.+]`, capped at
/// 64 characters. The cap is well above any flag seen in practice and
/// keeps the set narrow enough to exclude whitespace, parentheses, and
/// control characters without needing to enumerate every RFC exclusion.
fn is_valid_flag(flag: &str) -> bool { fn is_valid_flag(flag: &str) -> bool {
if flag.is_empty() || flag.len() > 64 { if flag.is_empty() || flag.len() > 64 {
return false; return false;
} }
// Strip optional leading backslash (system flags like \Seen).
let atom = flag.strip_prefix('\\').unwrap_or(flag); let atom = flag.strip_prefix('\\').unwrap_or(flag);
!atom.is_empty() !atom.is_empty()
&& atom && atom
@@ -70,19 +51,8 @@ fn validate_flags(flags: &[String]) -> Result<(), AppError> {
Ok(()) Ok(())
} }
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// How many seconds a folder's message cache is considered fresh.
const FOLDER_MESSAGES_TTL_SECS: u32 = 30; const FOLDER_MESSAGES_TTL_SECS: u32 = 30;
/// `GET /api/folders/:folder/messages?page=0&per_page=50`
///
/// Returns paginated messages using a cache-first strategy:
/// 1. If the folder was synced within `FOLDER_MESSAGES_TTL_SECS`, serve from cache.
/// 2. Otherwise do a lightweight IMAP SELECT to check for new messages and sync
/// only what's new.
pub async fn list_messages( pub async fn list_messages(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -154,7 +124,6 @@ pub async fn list_messages(
if !folder_fresh { if !folder_fresh {
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
// Do a lightweight IMAP SELECT to get folder status.
let status = imap_client let status = imap_client
.folder_status(&creds, &folder) .folder_status(&creds, &folder)
.await .await
@@ -201,8 +170,6 @@ pub async fn list_messages(
|| folder_invalidated; || folder_invalidated;
if needs_sync { if needs_sync {
// Cold start: no cached messages at all — fetch the ~100 most recent
// synchronously for a fast first paint, then background-sync the rest.
if (needs_full_sync || cached_count == 0) && status.exists > 0 { if (needs_full_sync || cached_count == 0) && status.exists > 0 {
let recent_start = status.uid_next.saturating_sub(100).max(1); let recent_start = status.uid_next.saturating_sub(100).max(1);
let recent_range = format!("{recent_start}:*"); let recent_range = format!("{recent_start}:*");
@@ -233,7 +200,6 @@ pub async fn list_messages(
.await .await
.map_err(AppError::InternalError)?; .map_err(AppError::InternalError)?;
// Spawn background task to fetch the remaining older headers.
if recent_start > 1 { if recent_start > 1 {
let remaining_range = (1, recent_start - 1); let remaining_range = (1, recent_start - 1);
tokio::spawn(sync_remaining_headers(BackgroundSyncParams { tokio::spawn(sync_remaining_headers(BackgroundSyncParams {
@@ -252,8 +218,6 @@ pub async fn list_messages(
syncing = true; syncing = true;
} }
} else { } else {
// An interrupted initial sync can leave gaps below the highest
// cached UID. Reconcile all server UIDs, fetching only missing ones.
let uid_range = (1, status.uid_next.saturating_sub(1)); let uid_range = (1, status.uid_next.saturating_sub(1));
tokio::spawn(sync_remaining_headers(BackgroundSyncParams { tokio::spawn(sync_remaining_headers(BackgroundSyncParams {
@@ -357,11 +321,6 @@ pub async fn list_messages(
.into_response()) .into_response())
} }
// ---------------------------------------------------------------------------
// Helpers for eager loading
// ---------------------------------------------------------------------------
/// Upsert fetched headers into the database and index them for search.
fn upsert_and_index_headers( fn upsert_and_index_headers(
conn: &rusqlite::Transaction<'_>, conn: &rusqlite::Transaction<'_>,
folder: &str, folder: &str,
@@ -369,7 +328,6 @@ fn upsert_and_index_headers(
search_engine: &Arc<SearchEngine>, search_engine: &Arc<SearchEngine>,
user_hash: &str, user_hash: &str,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
// Caller holds a checked HeaderSync write transaction through indexing.
let sync_keywords = db::mail_preferences::get_preferences(conn) let sync_keywords = db::mail_preferences::get_preferences(conn)
.map(|prefs| prefs.sync_imap_keywords) .map(|prefs| prefs.sync_imap_keywords)
.unwrap_or(false); .unwrap_or(false);
@@ -411,7 +369,6 @@ fn upsert_and_index_headers(
) )
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Populate denormalized known_addresses table.
db::contacts::populate_known_addresses(conn, from_address, from_name, &to_json, &cc_json) db::contacts::populate_known_addresses(conn, from_address, from_name, &to_json, &cc_json)
.map_err(|e| AppError::InternalError(format!("Known addresses error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Known addresses error: {e}")))?;
if sync_keywords { if sync_keywords {
@@ -420,7 +377,6 @@ fn upsert_and_index_headers(
} }
} }
// Index for search (skip Spam/Junk/Trash).
if !headers.is_empty() if !headers.is_empty()
&& !UserIndex::is_excluded_folder(folder) && !UserIndex::is_excluded_folder(folder)
&& let Ok(user_index) = search_engine.open_user_index(user_hash) && let Ok(user_index) = search_engine.open_user_index(user_hash)
@@ -452,7 +408,6 @@ fn upsert_and_index_headers(
Ok(()) Ok(())
} }
/// Bound each FETCH by existing messages, not by the size of gaps between UIDs.
fn header_chunks(uids: &[u32]) -> impl Iterator<Item = String> + '_ { fn header_chunks(uids: &[u32]) -> impl Iterator<Item = String> + '_ {
uids.chunks(250).map(|chunk| { uids.chunks(250).map(|chunk| {
chunk chunk
@@ -463,7 +418,6 @@ fn header_chunks(uids: &[u32]) -> impl Iterator<Item = String> + '_ {
}) })
} }
/// Parameters for a background sync task, bundled to avoid too-many-arguments.
struct BackgroundSyncParams { struct BackgroundSyncParams {
creds: ImapCredentials, creds: ImapCredentials,
folder: String, folder: String,
@@ -478,8 +432,6 @@ struct BackgroundSyncParams {
exists: u32, exists: u32,
} }
/// Background task: fetch remaining headers for a folder and notify the
/// frontend via EventBus when done. All errors are logged but non-fatal.
async fn sync_remaining_headers(params: BackgroundSyncParams) { async fn sync_remaining_headers(params: BackgroundSyncParams) {
let BackgroundSyncParams { let BackgroundSyncParams {
creds, creds,
@@ -567,9 +519,7 @@ async fn sync_remaining_headers(params: BackgroundSyncParams) {
Ok(superseded) Ok(superseded)
}) })
.await; .await;
// A completed mutation may invalidate a backfill in another folder.
// Wake the client to retry missing UIDs with fresh IMAP reads, not
// the rejected headers. Network errors wait for the next request.
if matches!(superseded, Ok(true)) { if matches!(superseded, Ok(true)) {
event_bus event_bus
.publish(&user_hash, MailEvent::FolderUpdated) .publish(&user_hash, MailEvent::FolderUpdated)
@@ -579,10 +529,6 @@ async fn sync_remaining_headers(params: BackgroundSyncParams) {
} }
} }
/// `GET /api/messages/:folder/:uid`
///
/// Returns the full message detail including body and attachment metadata.
/// Fetches from IMAP and caches if not already cached.
pub async fn get_message( pub async fn get_message(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -592,16 +538,12 @@ pub async fn get_message(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
// Open the per-user database.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Check SQLite cache first.
let cached_body = db::messages::get_cached_body(&conn, &folder, uid) let cached_body = db::messages::get_cached_body(&conn, &folder, uid)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Treat a cache hit with missing attachments_json as stale (pre-V006 cache).
// Re-fetch from IMAP so attachments and inline images are properly resolved.
let usable_cache = cached_body.filter(|c| c.attachments_json.is_some()); let usable_cache = cached_body.filter(|c| c.attachments_json.is_some());
let fetched_body = usable_cache.is_none(); let fetched_body = usable_cache.is_none();
@@ -638,15 +580,11 @@ pub async fn get_message(
) )
} }
} else { } else {
// Fetch from IMAP.
let body = imap_client let body = imap_client
.fetch_body(&creds, &folder, uid) .fetch_body(&creds, &folder, uid)
.await .await
.map_err(|e| match e { .map_err(|e| match e {
crate::imap::client::ImapError::MessageNotFound { .. } => { crate::imap::client::ImapError::MessageNotFound { .. } => {
// Message exists in local DB but not on IMAP server —
// remove the stale cache entry, search index entry, and
// invalidate the folder so the list refreshes.
let _ = db::messages::delete_message(&conn, &folder, uid); let _ = db::messages::delete_message(&conn, &folder, uid);
let _ = db::folders::invalidate_folder_freshness(&conn, &folder); let _ = db::folders::invalidate_folder_freshness(&conn, &folder);
if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) { if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) {
@@ -655,9 +593,6 @@ pub async fn get_message(
AppError::NotFound(format!("Message UID {uid} not found in folder {folder}")) AppError::NotFound(format!("Message UID {uid} not found in folder {folder}"))
} }
other => { other => {
// No cached body and IMAP is unreachable — this message
// can't be served. Remove the stale DB entry so it
// disappears from the message list.
let _ = db::messages::delete_message(&conn, &folder, uid); let _ = db::messages::delete_message(&conn, &folder, uid);
let _ = db::folders::invalidate_folder_freshness(&conn, &folder); let _ = db::folders::invalidate_folder_freshness(&conn, &folder);
if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) { if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) {
@@ -667,7 +602,6 @@ pub async fn get_message(
} }
})?; })?;
// Use HTML directly (frontend sandbox handles security).
let sanitized_html = body.text_html.clone(); let sanitized_html = body.text_html.clone();
let attachment_meta: Vec<AttachmentMeta> = body let attachment_meta: Vec<AttachmentMeta> = body
@@ -683,9 +617,6 @@ pub async fn get_message(
}) })
.collect(); .collect();
// Rewrite cid: URLs in the HTML to inline data URIs so the
// sandboxed iframe can display embedded images without needing
// network access.
let resolved_html = sanitized_html.map(|mut html| { let resolved_html = sanitized_html.map(|mut html| {
for att in &body.attachments { for att in &body.attachments {
if let Some(ref cid) = att.content_id { if let Some(ref cid) = att.content_id {
@@ -701,7 +632,6 @@ pub async fn get_message(
html html
}); });
// Serialize attachment metadata for caching.
let att_json = serde_json::to_string(&attachment_meta).ok(); let att_json = serde_json::to_string(&attachment_meta).ok();
let detected_theme = resolved_html let detected_theme = resolved_html
@@ -730,17 +660,12 @@ pub async fn get_message(
) )
}; };
// Get the message header from cache (use efficient single-message lookup).
// If the header hasn't been synced yet (e.g. DB was cleared and sync is
// still running), fall back to parsing the raw headers we already fetched.
let msg = db::messages::get_single_message(&conn, &folder, uid) let msg = db::messages::get_single_message(&conn, &folder, uid)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let msg = match msg { let msg = match msg {
Some(m) => m, Some(m) => m,
None => { None => {
// Parse header fields from raw_headers so we can still return a
// useful response even when the message list hasn't been synced.
let parsed = mail_parser::MessageParser::default().parse(raw_headers.as_bytes()); let parsed = mail_parser::MessageParser::default().parse(raw_headers.as_bytes());
let subject = parsed let subject = parsed
.as_ref() .as_ref()
@@ -817,8 +742,6 @@ pub async fn get_message(
} }
}; };
// Re-index message with full body text for search.
// Skip indexing for Spam/Junk/Trash folders.
if fetched_body if fetched_body
&& let Some(ref text) = body_text && let Some(ref text) = body_text
&& !UserIndex::is_excluded_folder(&folder) && !UserIndex::is_excluded_folder(&folder)
@@ -843,7 +766,6 @@ pub async fn get_message(
}); });
} }
// Build thread using full References chain.
let thread_messages = if let Some(ref message_id) = msg.message_id { let thread_messages = if let Some(ref message_id) = msg.message_id {
db::messages::get_full_thread(&conn, message_id, msg.references_header.as_deref()) db::messages::get_full_thread(&conn, message_id, msg.references_header.as_deref())
.unwrap_or_default() .unwrap_or_default()
@@ -901,9 +823,6 @@ pub async fn get_message(
.into_response()) .into_response())
} }
/// `PATCH /api/messages/:folder/:uid/flags`
///
/// Updates message flags on the IMAP server and in the SQLite cache.
pub async fn update_flags( pub async fn update_flags(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -915,7 +834,6 @@ pub async fn update_flags(
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
// Convert flags to &str slices for the IMAP client.
let flag_refs: Vec<&str> = body.flags.iter().map(|s| s.as_str()).collect(); let flag_refs: Vec<&str> = body.flags.iter().map(|s| s.as_str()).collect();
if body.add { if body.add {
@@ -930,7 +848,6 @@ pub async fn update_flags(
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
} }
// Update SQLite cache: read current flags, add/remove, write back.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -965,17 +882,12 @@ pub async fn update_flags(
db::messages::update_message_flags(&conn, &folder, uid, &new_flags_csv) db::messages::update_message_flags(&conn, &folder, uid, &new_flags_csv)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Refresh unread count after flag change.
db::folders::refresh_unread_count(&conn, &folder) db::folders::refresh_unread_count(&conn, &folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
} }
/// `POST /api/messages/move`
///
/// Moves a message from one folder to another on the IMAP server and
/// removes it from the source folder in SQLite cache.
pub async fn move_message_handler( pub async fn move_message_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -991,8 +903,6 @@ pub async fn move_message_handler(
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
// Snapshot the cached source metadata before the IMAP MOVE removes the source UID.
// Do not keep a rusqlite connection alive across an await.
let source_snapshot = { let source_snapshot = {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -1004,10 +914,6 @@ pub async fn move_message_handler(
.map(|message| !message.flags.contains("\\Seen")) .map(|message| !message.flags.contains("\\Seen"))
.unwrap_or(false); .unwrap_or(false);
// async-imap currently discards COPYUID from UID MOVE. Capture UIDNEXT before
// the move, then resolve the new UID from the moved message's exact Message-ID.
// If the server cannot provide enough information, the move still succeeds but
// the UI will intentionally not offer an unsafe Undo action.
let destination_uid_floor = if source_snapshot let destination_uid_floor = if source_snapshot
.as_ref() .as_ref()
.and_then(|message| message.message_id.as_deref()) .and_then(|message| message.message_id.as_deref())
@@ -1022,7 +928,6 @@ pub async fn move_message_handler(
None None
}; };
// Move on IMAP server.
imap_client imap_client
.move_message(&creds, &body.from_folder, body.uid, &body.to_folder) .move_message(&creds, &body.from_folder, body.uid, &body.to_folder)
.await .await
@@ -1068,14 +973,9 @@ pub async fn move_message_handler(
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Delete from source folder cache. We don't keep the row in the destination
// because the UID changes after an IMAP MOVE, and a stale UID would cause
// 404s when trying to fetch the message body.
db::messages::remove_after_mutation(&conn, &body.from_folder, body.uid) db::messages::remove_after_mutation(&conn, &body.from_folder, body.uid)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Remove the old folder/UID pair from search too. Destination UIDs are
// discovered during folder sync; Spam/Junk intentionally remain unindexed.
let user_hash = session.user_hash.clone(); let user_hash = session.user_hash.clone();
let from_folder = body.from_folder.clone(); let from_folder = body.from_folder.clone();
let uid = body.uid; let uid = body.uid;
@@ -1085,25 +985,20 @@ pub async fn move_message_handler(
}) })
.await; .await;
if !matches!(search_result, Ok(Ok(()))) { if !matches!(search_result, Ok(Ok(()))) {
// The IMAP move already succeeded: do not trigger a false client rollback.
tracing::warn!( tracing::warn!(
?search_result, ?search_result,
"Failed to remove moved message from search index" "Failed to remove moved message from search index"
); );
} }
// Refresh source folder unread count (now accurate since the row is gone).
db::folders::refresh_unread_count(&conn, &body.from_folder) db::folders::refresh_unread_count(&conn, &body.from_folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Bump destination folder unread count if the moved message was unread.
if was_unread { if was_unread {
db::folders::adjust_unread_count(&conn, &body.to_folder, 1) db::folders::adjust_unread_count(&conn, &body.to_folder, 1)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
} }
// Invalidate destination folder cache so the next list request forces an
// IMAP resync and picks up the moved message with its new UID.
db::folders::invalidate_folder_freshness(&conn, &body.to_folder) db::folders::invalidate_folder_freshness(&conn, &body.to_folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -1118,9 +1013,6 @@ pub async fn move_message_handler(
Ok(Json(serde_json::json!({ "status": "ok", "undo_move": undo_move })).into_response()) Ok(Json(serde_json::json!({ "status": "ok", "undo_move": undo_move })).into_response())
} }
/// `POST /api/messages/copy`
///
/// Copies a message to another IMAP folder and invalidates the destination cache.
pub async fn copy_message_handler( pub async fn copy_message_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -1146,9 +1038,6 @@ pub async fn copy_message_handler(
Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
} }
/// `GET /api/messages/:folder/:uid/attachments/:attachment_id`
///
/// Downloads an attachment by its index from the message.
pub async fn download_attachment( pub async fn download_attachment(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -1157,12 +1046,10 @@ pub async fn download_attachment(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
// Parse the attachment index.
let index: usize = attachment_id let index: usize = attachment_id
.parse() .parse()
.map_err(|_| AppError::BadRequest(format!("Invalid attachment id: {attachment_id}")))?; .map_err(|_| AppError::BadRequest(format!("Invalid attachment id: {attachment_id}")))?;
// Fetch the full message body from IMAP.
let body = imap_client let body = imap_client
.fetch_body(&creds, &folder, uid) .fetch_body(&creds, &folder, uid)
.await .await
@@ -1173,28 +1060,21 @@ pub async fn download_attachment(
other => AppError::ServiceUnavailable(format!("IMAP error: {other}")), other => AppError::ServiceUnavailable(format!("IMAP error: {other}")),
})?; })?;
// Find the attachment by index.
let attachment = body.attachments.into_iter().nth(index).ok_or_else(|| { let attachment = body.attachments.into_iter().nth(index).ok_or_else(|| {
AppError::NotFound(format!( AppError::NotFound(format!(
"Attachment {attachment_id} not found on message UID {uid}" "Attachment {attachment_id} not found on message UID {uid}"
)) ))
})?; })?;
// Build the response with appropriate headers.
let raw_filename = attachment let raw_filename = attachment
.filename .filename
.unwrap_or_else(|| format!("attachment_{index}")); .unwrap_or_else(|| format!("attachment_{index}"));
// Strip ASCII control characters (U+0000-U+001F and U+007F). MIME
// decoders can surface them via quoted-pair escapes in malformed emails,
// and HeaderValue rejects those bytes. HeaderValue::from_str routes
// through is_valid (accepts 0x09, 0x20-0x7E, 0x80-0xFF; rejects
// 0x00-0x08, 0x0A-0x1F, 0x7F), so non-ASCII UTF-8 bytes are fine.
let filename: String = raw_filename let filename: String = raw_filename
.chars() .chars()
.filter(|c| !c.is_ascii_control()) .filter(|c| !c.is_ascii_control())
.collect(); .collect();
// Fall back to a safe default if stripping left nothing (e.g. the
// original filename was composed entirely of control characters).
let filename = if filename.is_empty() { let filename = if filename.is_empty() {
format!("attachment_{index}") format!("attachment_{index}")
} else { } else {
@@ -1203,8 +1083,6 @@ pub async fn download_attachment(
let content_type = let content_type =
preview_content_type(&attachment.content_type, Some(&filename), &attachment.data); preview_content_type(&attachment.content_type, Some(&filename), &attachment.data);
// Use inline disposition for types the browser can display natively
// (PDF, images) so the preview works; use attachment for everything else.
let is_inline = content_type == "application/pdf" let is_inline = content_type == "application/pdf"
|| content_type.starts_with("image/") || content_type.starts_with("image/")
|| content_type.starts_with("text/"); || content_type.starts_with("text/");
@@ -1234,14 +1112,8 @@ fn preview_content_type(content_type: &str, filename: Option<&str>, data: &[u8])
} }
} }
/// Maximum size accepted for importing a single RFC822/.eml message.
const MAX_EML_IMPORT_SIZE: usize = 50 * 1024 * 1024; const MAX_EML_IMPORT_SIZE: usize = 50 * 1024 * 1024;
/// `POST /api/folders/:folder/import-eml`
///
/// Imports one raw RFC822 message into the selected IMAP folder using APPEND.
/// The server remains the source of truth: after APPEND the local folder cache
/// is invalidated so the next list request discovers the server-assigned UID.
pub async fn import_eml( pub async fn import_eml(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -1279,8 +1151,6 @@ pub async fn import_eml(
)); ));
} }
// Reject arbitrary blobs early. We do not rewrite or normalize the source: the
// exact uploaded RFC822 bytes are appended to IMAP after this structural check.
if mail_parser::MessageParser::default() if mail_parser::MessageParser::default()
.parse(bytes.as_ref()) .parse(bytes.as_ref())
.is_none() .is_none()
@@ -1314,7 +1184,6 @@ pub async fn import_eml(
.into_response()) .into_response())
} }
/// Download the complete RFC822 source as an .eml file.
pub async fn download_eml_source( pub async fn download_eml_source(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -1402,9 +1271,6 @@ mod preview_tests {
} }
} }
/// `DELETE /api/messages/:folder/:uid`
///
/// Permanently removes a message from the IMAP server and SQLite cache.
pub async fn delete_message_handler( pub async fn delete_message_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -1413,19 +1279,16 @@ pub async fn delete_message_handler(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let creds = build_creds(&session, &config)?; let creds = build_creds(&session, &config)?;
// Expunge on IMAP server.
imap_client imap_client
.expunge_message(&creds, &folder, uid) .expunge_message(&creds, &folder, uid)
.await .await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?; .map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
// Delete from SQLite cache.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
db::messages::remove_after_mutation(&conn, &folder, uid) db::messages::remove_after_mutation(&conn, &folder, uid)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Refresh unread count for folder.
db::folders::refresh_unread_count(&conn, &folder) db::folders::refresh_unread_count(&conn, &folder)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -1539,8 +1402,6 @@ mod sync_tests {
exists: 1, exists: 1,
})); }));
// The IMAP batch has already been snapshotted. Complete the real handler
// before allowing the background writer to see those stale headers.
gate.0.notified().await; gate.0.notified().await;
let imap_client: Arc<dyn ImapClient> = client.clone(); let imap_client: Arc<dyn ImapClient> = client.clone();
let response = if move_message { let response = if move_message {
@@ -1583,7 +1444,7 @@ mod sync_tests {
"superseded sync must request fresh missing-UID reconciliation" "superseded sync must request fresh missing-UID reconciliation"
); );
assert!(events.try_recv().is_err()); assert!(events.try_recv().is_err());
// A move's index deletion also must not be undone by rejected headers.
if move_message { if move_message {
let index = search_engine.open_user_index(user_hash).unwrap(); let index = search_engine.open_user_index(user_hash).unwrap();
assert!( assert!(
@@ -1599,8 +1460,6 @@ mod sync_tests {
); );
} }
// New destination UIDs (even the same numeric UID in another folder)
// remain valid, as do future arrivals in the source folder.
let next_sync = HeaderSync::capture(&conn).unwrap(); let next_sync = HeaderSync::capture(&conn).unwrap();
let transaction = next_sync.begin_write(&conn).unwrap(); let transaction = next_sync.begin_write(&conn).unwrap();
upsert_and_index_headers( upsert_and_index_headers(
@@ -1744,8 +1603,6 @@ mod sync_tests {
MailEvent::FolderUpdated MailEvent::FolderUpdated
)); ));
// The next actual list request must recover UIDs below max_cached_uid.
// A fresh server snapshot omits only the UID deleted in this folder.
let remaining = (1..=1000) let remaining = (1..=1000)
.filter(|uid| other_folder || *uid != 950) .filter(|uid| other_folder || *uid != 950)
.map(sync_header) .map(sync_header)
@@ -1868,7 +1725,6 @@ mod sync_tests {
#[tokio::test] #[tokio::test]
async fn empty_background_sync_records_completion() { async fn empty_background_sync_records_completion() {
// Both an exhausted range and an empty server listing are successful syncs.
for uid_range in [(1, 0), (1, 100)] { for uid_range in [(1, 0), (1, 100)] {
let data_dir = tempfile::tempdir().unwrap(); let data_dir = tempfile::tempdir().unwrap();
let path = data_dir.path().to_str().unwrap(); let path = data_dir.path().to_str().unwrap();
@@ -1893,7 +1749,7 @@ mod sync_tests {
host: "localhost".into(), host: "localhost".into(),
port: 143, port: 143,
tls: false, tls: false,
tls_verify: false, tls_verify: false,
email: "test@example.com".into(), email: "test@example.com".into(),
password: "test".into(), password: "test".into(),
}, },
@@ -1963,7 +1819,7 @@ mod sync_tests {
host: "localhost".into(), host: "localhost".into(),
port: 143, port: 143,
tls: false, tls: false,
tls_verify: false, tls_verify: false,
email: "test@example.com".into(), email: "test@example.com".into(),
password: "test".into(), password: "test".into(),
}, },
-13
View File
@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
pub use crate::email_theme::EmailTheme; pub use crate::email_theme::EmailTheme;
/// Default page size for paginated list queries.
pub fn default_per_page() -> u32 { pub fn default_per_page() -> u32 {
50 50
} }
@@ -11,7 +10,6 @@ pub fn default_sort() -> String {
"date_desc".to_string() "date_desc".to_string()
} }
/// Query parameters for `GET /api/folders/:folder/messages`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub(crate) struct ListMessagesQuery { pub(crate) struct ListMessagesQuery {
#[serde(default)] #[serde(default)]
@@ -22,14 +20,12 @@ pub(crate) struct ListMessagesQuery {
pub(crate) sort: String, pub(crate) sort: String,
} }
/// Request body for `PATCH /api/messages/:folder/:uid/flags`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub(crate) struct UpdateFlagsRequest { pub(crate) struct UpdateFlagsRequest {
pub(crate) flags: Vec<String>, pub(crate) flags: Vec<String>,
pub(crate) add: bool, pub(crate) add: bool,
} }
/// Request body for `POST /api/messages/move`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub(crate) struct MoveMessageRequest { pub(crate) struct MoveMessageRequest {
pub(crate) from_folder: String, pub(crate) from_folder: String,
@@ -44,7 +40,6 @@ pub(crate) struct CopyMessageRequest {
pub(crate) uid: u32, pub(crate) uid: u32,
} }
/// Response envelope for `GET /api/folders/:folder/messages`.
#[derive(Serialize)] #[derive(Serialize)]
pub(crate) struct ListMessagesResponse { pub(crate) struct ListMessagesResponse {
pub(crate) messages: Vec<MessageSummary>, pub(crate) messages: Vec<MessageSummary>,
@@ -55,7 +50,6 @@ pub(crate) struct ListMessagesResponse {
pub(crate) flagged_on_top: bool, pub(crate) flagged_on_top: bool,
} }
/// A message summary in the list response.
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct MessageSummary { pub struct MessageSummary {
pub uid: u32, pub uid: u32,
@@ -73,20 +67,16 @@ pub struct MessageSummary {
pub tags: Vec<crate::db::tags::MessageTag>, pub tags: Vec<crate::db::tags::MessageTag>,
} }
/// An email address entry for the detail response.
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub(crate) struct AddressEntry { pub(crate) struct AddressEntry {
pub(crate) name: Option<String>, pub(crate) name: Option<String>,
pub(crate) address: String, pub(crate) address: String,
} }
/// Parse a JSON-encoded address list string (e.g. from the SQLite cache) into
/// a `Vec<AddressEntry>`. Returns an empty vec on parse failure.
pub(crate) fn parse_address_list(json_str: &str) -> Vec<AddressEntry> { pub(crate) fn parse_address_list(json_str: &str) -> Vec<AddressEntry> {
serde_json::from_str(json_str).unwrap_or_default() serde_json::from_str(json_str).unwrap_or_default()
} }
/// Split a comma-separated flags string into a `Vec<String>`.
pub(crate) fn parse_flags(flags_csv: &str) -> Vec<String> { pub(crate) fn parse_flags(flags_csv: &str) -> Vec<String> {
if flags_csv.is_empty() { if flags_csv.is_empty() {
return vec![]; return vec![];
@@ -98,7 +88,6 @@ pub(crate) fn parse_flags(flags_csv: &str) -> Vec<String> {
.collect() .collect()
} }
/// Response for `GET /api/messages/:folder/:uid`.
#[derive(Serialize)] #[derive(Serialize)]
pub(crate) struct MessageDetailResponse { pub(crate) struct MessageDetailResponse {
pub(crate) uid: u32, pub(crate) uid: u32,
@@ -122,7 +111,6 @@ pub(crate) struct MessageDetailResponse {
pub(crate) email_theme: Option<EmailTheme>, pub(crate) email_theme: Option<EmailTheme>,
} }
/// A message summary within a thread.
#[derive(Serialize)] #[derive(Serialize)]
pub(crate) struct ThreadMessage { pub(crate) struct ThreadMessage {
pub(crate) uid: u32, pub(crate) uid: u32,
@@ -141,7 +129,6 @@ pub(crate) struct ThreadMessage {
pub(crate) snippet: String, pub(crate) snippet: String,
} }
/// Attachment metadata (without the binary data).
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub(crate) struct AttachmentMeta { pub(crate) struct AttachmentMeta {
pub(crate) id: String, pub(crate) id: String,
+5 -48
View File
@@ -48,14 +48,6 @@ use crate::realtime::events::EventBus;
use crate::realtime::idle::IdleManager; use crate::realtime::idle::IdleManager;
use crate::smtp::client::SmtpClient; use crate::smtp::client::SmtpClient;
/// Per-IP key extractor that supports trusted reverse proxies.
///
/// When the direct peer IP is in the configured `trusted_proxies` list,
/// `X-Forwarded-For` is walked from right to left. Trusted proxy hops on the
/// right are skipped and the nearest untrusted address becomes the client IP.
/// If the header is missing or malformed, the direct peer IP is used. Falls
/// back to loopback when `ConnectInfo<SocketAddr>` is unavailable (e.g. in
/// unit tests).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct ProxyAwareIpExtractor { struct ProxyAwareIpExtractor {
trusted_proxies: Vec<TrustedProxy>, trusted_proxies: Vec<TrustedProxy>,
@@ -109,28 +101,6 @@ impl KeyExtractor for ProxyAwareIpExtractor {
} }
} }
/// Assembles all application routes into an Axum Router.
///
/// Route layout:
/// - `GET /api/health` / `/api/ready` — liveness/readiness (public)
/// - `POST /api/auth/login` — login (public, CSRF only)
/// - `GET /api/auth/session` — get session (auth_guard + CSRF)
/// - `POST /api/auth/logout` — logout (auth_guard + CSRF)
/// - `GET /api/folders` — list folders (auth_guard + CSRF)
/// - `GET /api/folders/:folder/messages` — list messages (auth_guard + CSRF)
/// - `GET /api/messages/:folder/:uid` — get message detail (auth_guard + CSRF)
/// - `PATCH /api/messages/:folder/:uid/flags` — update flags (auth_guard + CSRF)
/// - `GET /api/messages/:folder/:uid/attachments/:attachment_id` — download attachment (auth_guard + CSRF)
/// - `POST /api/messages/move` — move message (auth_guard + CSRF)
/// - `DELETE /api/messages/:folder/:uid` — delete message (auth_guard + CSRF)
///
/// All non-API paths are served by the embedded RustBox UI.
/// The UI assets are compiled into the same executable; there is no frontend runtime.
///
/// Middleware layers:
/// - tower-http tracing
/// - CSRF protection on auth routes
/// - auth_guard on protected routes
pub fn create_router( pub fn create_router(
config: Arc<AppConfig>, config: Arc<AppConfig>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
@@ -140,7 +110,6 @@ pub fn create_router(
event_bus: Arc<EventBus>, event_bus: Arc<EventBus>,
idle_manager: Arc<IdleManager>, idle_manager: Arc<IdleManager>,
) -> Router { ) -> Router {
// Rate-limit login: replenish 1 token every 12 s, burst of 5.
let governor_conf = GovernorConfigBuilder::default() let governor_conf = GovernorConfigBuilder::default()
.key_extractor(ProxyAwareIpExtractor { .key_extractor(ProxyAwareIpExtractor {
trusted_proxies: config.parsed_trusted_proxies(), trusted_proxies: config.parsed_trusted_proxies(),
@@ -150,15 +119,12 @@ pub fn create_router(
.finish() .finish()
.expect("valid governor config"); .expect("valid governor config");
// Public auth route: GovernorLayer (outermost) -> CSRF -> handler.
let public_auth = Router::new() let public_auth = Router::new()
.route("/login", post(auth::login)) .route("/login", post(auth::login))
.route("/policy", get(auth::auth_policy)) .route("/policy", get(auth::auth_policy))
.layer(middleware::from_fn(csrf_protection)) .layer(middleware::from_fn(csrf_protection))
.layer(GovernorLayer::new(governor_conf)); .layer(GovernorLayer::new(governor_conf));
// Browser-bound routes (no auth_guard, no rate limit).
// These routes only need the browser cookie, not full auth.
let browser_routes = Router::new() let browser_routes = Router::new()
.route("/accounts", get(auth::list_accounts)) .route("/accounts", get(auth::list_accounts))
.route( .route(
@@ -166,7 +132,6 @@ pub fn create_router(
delete(auth::remove_account).layer(middleware::from_fn(csrf_protection)), delete(auth::remove_account).layer(middleware::from_fn(csrf_protection)),
); );
// Protected auth routes (auth_guard + CSRF).
let protected_auth = Router::new() let protected_auth = Router::new()
.route("/session", get(auth::get_session)) .route("/session", get(auth::get_session))
.route("/sessions", get(auth::list_active_sessions)) .route("/sessions", get(auth::list_active_sessions))
@@ -181,9 +146,9 @@ pub fn create_router(
.merge(browser_routes) .merge(browser_routes)
.merge(protected_auth); .merge(protected_auth);
// Protected data routes (auth_guard + CSRF). let attachment_body_limit = config
// Multipart framing needs a small allowance on top of the configured per-file limit. .max_attachment_size_bytes()
let attachment_body_limit = config.max_attachment_size_bytes().saturating_add(1024 * 1024); .saturating_add(1024 * 1024);
let protected_data = Router::new() let protected_data = Router::new()
.route( .route(
"/folders", "/folders",
@@ -240,7 +205,8 @@ pub fn create_router(
) )
.route( .route(
"/drafts/{draft_id}/attachments", "/drafts/{draft_id}/attachments",
post(attachments::upload_attachment).layer(DefaultBodyLimit::max(attachment_body_limit)), post(attachments::upload_attachment)
.layer(DefaultBodyLimit::max(attachment_body_limit)),
) )
.route( .route(
"/drafts/{draft_id}/attachments/{attachment_id}", "/drafts/{draft_id}/attachments/{attachment_id}",
@@ -482,8 +448,6 @@ pub fn create_router(
.layer(middleware::from_fn(auth_guard)) .layer(middleware::from_fn(auth_guard))
.layer(middleware::from_fn(csrf_protection)); .layer(middleware::from_fn(csrf_protection));
// WebSocket route — auth is handled inside the handler via cookie,
// so it bypasses CSRF and auth_guard middleware.
let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler)); let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler));
let api_router = Router::new() let api_router = Router::new()
@@ -493,21 +457,14 @@ pub fn create_router(
.merge(ws_route) .merge(ws_route)
.merge(protected_data); .merge(protected_data);
// RustBox serves API and UI from the same Rust process.
let inner = Router::new() let inner = Router::new()
.nest("/api", api_router) .nest("/api", api_router)
// An explicit index route is required for the root of a nested base path
// (for example `/mail/`). The fallback still handles SPA deep links.
.route("/", get(crate::ui::serve_ui)) .route("/", get(crate::ui::serve_ui))
.fallback(crate::ui::serve_ui); .fallback(crate::ui::serve_ui);
// Use the same base path for routing and authentication cookies.
let router = match config.normalized_base_path() { let router = match config.normalized_base_path() {
"/" => inner, "/" => inner,
base_path => { base_path => {
// Axum nests an inner `/` route at the prefix itself (`/mail`),
// not at the trailing-slash variant (`/mail/`). Serve that variant
// explicitly so both URLs load the same embedded SPA.
let base_path_slash = format!("{}/", base_path.trim_end_matches('/')); let base_path_slash = format!("{}/", base_path.trim_end_matches('/'));
Router::new() Router::new()
.route(&base_path_slash, get(crate::ui::serve_ui)) .route(&base_path_slash, get(crate::ui::serve_ui))
-2
View File
@@ -9,7 +9,6 @@ use crate::db;
use crate::db::notification_preferences::UpdateNotificationPreferences; use crate::db::notification_preferences::UpdateNotificationPreferences;
use crate::error::AppError; use crate::error::AppError;
/// `GET /api/settings/notifications`
pub async fn get_notification_preferences( pub async fn get_notification_preferences(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -23,7 +22,6 @@ pub async fn get_notification_preferences(
Ok(Json(prefs).into_response()) Ok(Json(prefs).into_response())
} }
/// `PUT /api/settings/notifications`
pub async fn update_notification_preferences( pub async fn update_notification_preferences(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
+2 -11
View File
@@ -13,17 +13,11 @@ use crate::imap::client::{ImapClient, ImapCredentials};
#[derive(Serialize)] #[derive(Serialize)]
struct QuotaResponse { struct QuotaResponse {
/// Storage used in bytes, or null if unavailable.
usage_bytes: Option<u64>, usage_bytes: Option<u64>,
/// Storage limit in bytes, or null if unlimited/unavailable.
limit_bytes: Option<u64>, limit_bytes: Option<u64>,
} }
/// `GET /api/quota`
///
/// Returns the mailbox storage quota for the authenticated user.
/// Tries IMAP GETQUOTAROOT first; if no quota is configured, falls back to
/// the cached message sizes (an estimate during sync). An empty cache uses IMAP.
pub async fn get_quota( pub async fn get_quota(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -38,7 +32,6 @@ pub async fn get_quota(
password: session.password.clone(), password: session.password.clone(),
}; };
// Try IMAP GETQUOTAROOT first.
let quota = imap_client.get_quota(&creds).await.unwrap_or(None); let quota = imap_client.get_quota(&creds).await.unwrap_or(None);
let resp = match quota { let resp = match quota {
@@ -63,8 +56,7 @@ pub async fn get_quota(
}) })
.into_response()); .into_response());
} }
// Empty cache: sum RFC822.SIZE across all folders.
// Run SQLite access in spawn_blocking to avoid blocking the executor.
let data_dir = config.data_dir.clone(); let data_dir = config.data_dir.clone();
let user_hash = session.user_hash.clone(); let user_hash = session.user_hash.clone();
let folders: Vec<String> = tokio::task::spawn_blocking(move || { let folders: Vec<String> = tokio::task::spawn_blocking(move || {
@@ -81,7 +73,6 @@ pub async fn get_quota(
.map_err(|e| AppError::InternalError(format!("Task error: {e}")))? .map_err(|e| AppError::InternalError(format!("Task error: {e}")))?
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Fetch sizes with a 90s overall timeout.
let total = tokio::time::timeout( let total = tokio::time::timeout(
Duration::from_secs(90), Duration::from_secs(90),
crate::imap::pool::background(async { crate::imap::pool::background(async {
+21 -80
View File
@@ -11,11 +11,6 @@ use crate::db;
use crate::error::AppError; use crate::error::AppError;
use crate::search::engine::{SearchEngine, SearchQuery}; use crate::search::engine::{SearchEngine, SearchQuery};
// ---------------------------------------------------------------------------
// Search query parser
// ---------------------------------------------------------------------------
/// Intermediate result of parsing structured operators out of a query string.
struct ParsedQuery { struct ParsedQuery {
text: String, text: String,
subject_only: Option<String>, subject_only: Option<String>,
@@ -29,11 +24,6 @@ struct ParsedQuery {
flagged: Option<bool>, flagged: Option<bool>,
} }
/// Parse a search query string extracting structured operators like `from:`,
/// `to:`, `subject:`, `in:`/`folder:`, `date:`, `after:`, `before:`, and
/// `has:attachment`. Remaining text becomes the free-text query.
///
/// Supports quoted values: `from:"John Doe"`, `subject:"meeting notes"`.
fn parse_search_query(input: &str) -> ParsedQuery { fn parse_search_query(input: &str) -> ParsedQuery {
let mut text_parts: Vec<String> = Vec::new(); let mut text_parts: Vec<String> = Vec::new();
let mut subject_only: Option<String> = None; let mut subject_only: Option<String> = None;
@@ -88,7 +78,6 @@ fn parse_search_query(input: &str) -> ParsedQuery {
flagged = Some(false) flagged = Some(false)
} }
_ => { _ => {
// Unknown operator, treat as plain text
text_parts.push(token); text_parts.push(token);
} }
} }
@@ -111,9 +100,6 @@ fn parse_search_query(input: &str) -> ParsedQuery {
} }
} }
/// Tokenize a query string, respecting quoted values attached to operators.
/// For example: `from:"John Doe" hello world` yields
/// `["from:John Doe", "hello", "world"]`.
fn tokenize_query(input: &str) -> Vec<String> { fn tokenize_query(input: &str) -> Vec<String> {
let mut tokens = Vec::new(); let mut tokens = Vec::new();
let mut chars = input.chars().peekable(); let mut chars = input.chars().peekable();
@@ -126,11 +112,10 @@ fn tokenize_query(input: &str) -> Vec<String> {
} }
chars.next(); chars.next();
} else if ch == '"' { } else if ch == '"' {
// Start of a quoted section chars.next();
chars.next(); // consume opening quote
while let Some(&qch) = chars.peek() { while let Some(&qch) = chars.peek() {
if qch == '"' { if qch == '"' {
chars.next(); // consume closing quote chars.next();
break; break;
} }
current.push(qch); current.push(qch);
@@ -149,12 +134,11 @@ fn tokenize_query(input: &str) -> Vec<String> {
tokens tokens
} }
/// Split a token into (operator, value) if it matches `operator:value` pattern.
fn split_operator(token: &str) -> Option<(String, String)> { fn split_operator(token: &str) -> Option<(String, String)> {
if let Some(colon_pos) = token.find(':') { if let Some(colon_pos) = token.find(':') {
let op = &token[..colon_pos]; let op = &token[..colon_pos];
let val = &token[colon_pos + 1..]; let val = &token[colon_pos + 1..];
// Only treat as operator if the operator part is alphabetic and value is non-empty
if !op.is_empty() && op.chars().all(|c| c.is_ascii_alphabetic()) && !val.is_empty() { if !op.is_empty() && op.chars().all(|c| c.is_ascii_alphabetic()) && !val.is_empty() {
return Some((op.to_string(), val.to_string())); return Some((op.to_string(), val.to_string()));
} }
@@ -162,15 +146,12 @@ fn split_operator(token: &str) -> Option<(String, String)> {
None None
} }
/// Parse a `YYYY-MM-DD` date string into a start-of-day and end-of-day epoch
/// (UTC) pair.
fn parse_date_range(date_str: &str) -> Option<(i64, i64)> { fn parse_date_range(date_str: &str) -> Option<(i64, i64)> {
let start = parse_date_start(date_str)?; let start = parse_date_start(date_str)?;
let end = start + 86_400 - 1; // end of day let end = start + 86_400 - 1;
Some((start, end)) Some((start, end))
} }
/// Parse a `YYYY-MM-DD` date string into a start-of-day epoch (UTC).
fn parse_date_start(date_str: &str) -> Option<i64> { fn parse_date_start(date_str: &str) -> Option<i64> {
let parts: Vec<&str> = date_str.split('-').collect(); let parts: Vec<&str> = date_str.split('-').collect();
if parts.len() != 3 { if parts.len() != 3 {
@@ -184,16 +165,12 @@ fn parse_date_start(date_str: &str) -> Option<i64> {
return None; return None;
} }
// Convert to Unix epoch using a simplified calculation (UTC).
// Days from year 1970 to the given date.
let mut total_days: i64 = 0; let mut total_days: i64 = 0;
// Days from full years
for y in 1970..year { for y in 1970..year {
total_days += if is_leap_year(y) { 366 } else { 365 }; total_days += if is_leap_year(y) { 366 } else { 365 };
} }
// Days from full months in the target year
let days_in_months = [ let days_in_months = [
31, 31,
28 + i64::from(is_leap_year(year)), 28 + i64::from(is_leap_year(year)),
@@ -221,44 +198,37 @@ fn is_leap_year(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0 (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
} }
// ---------------------------------------------------------------------------
// Query parameters
// ---------------------------------------------------------------------------
/// Query parameters for `GET /api/search`.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SearchParams { pub struct SearchParams {
/// The search text (required, must not be empty).
pub q: Option<String>, pub q: Option<String>,
/// Optional folder filter.
pub folder: Option<String>, pub folder: Option<String>,
/// Optional subject filter.
pub subject: Option<String>, pub subject: Option<String>,
/// Optional from address filter.
pub from: Option<String>, pub from: Option<String>,
/// Optional to address filter.
pub to: Option<String>, pub to: Option<String>,
/// Optional date range start (Unix epoch seconds).
pub date_from: Option<i64>, pub date_from: Option<i64>,
/// Optional date range end (Unix epoch seconds).
pub date_to: Option<i64>, pub date_to: Option<i64>,
/// Optional attachment filter.
pub has_attachment: Option<bool>, pub has_attachment: Option<bool>,
/// Optional unread/read filter.
pub unread: Option<bool>, pub unread: Option<bool>,
/// Optional flagged/unflagged filter.
pub flagged: Option<bool>, pub flagged: Option<bool>,
/// Maximum number of results (default 50).
#[serde(default = "default_limit")] #[serde(default = "default_limit")]
pub limit: usize, pub limit: usize,
/// Offset for pagination (default 0).
#[serde(default)] #[serde(default)]
pub offset: usize, pub offset: usize,
/// Sort order: "date_desc" (default) or "date_asc".
#[serde(default = "default_sort")] #[serde(default = "default_sort")]
pub sort: String, pub sort: String,
/// Materialize every matching cached result. Intended for explicit
/// "select all search results" bulk actions, not normal rendering.
#[serde(default)] #[serde(default)]
pub all: bool, pub all: bool,
} }
@@ -271,11 +241,6 @@ fn default_limit() -> usize {
10_000 10_000
} }
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
/// Response envelope for `GET /api/search`.
#[derive(Serialize)] #[derive(Serialize)]
pub struct SearchResponse { pub struct SearchResponse {
pub results: Vec<SearchResultItem>, pub results: Vec<SearchResultItem>,
@@ -283,7 +248,6 @@ pub struct SearchResponse {
pub query: String, pub query: String,
} }
/// A single search result item enriched with message metadata from SQLite.
#[derive(Serialize)] #[derive(Serialize)]
pub struct SearchResultItem { pub struct SearchResultItem {
pub uid: u32, pub uid: u32,
@@ -299,21 +263,12 @@ pub struct SearchResultItem {
pub snippet: String, pub snippet: String,
} }
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
/// `GET /api/search?q=text&folder=INBOX&from=alice&date_from=...&date_to=...&has_attachment=true&limit=50&offset=0`
///
/// Searches the user's messages using both SQLite (for header fields) and
/// Tantivy (for body text). Results are merged and deduplicated.
pub async fn search_messages( pub async fn search_messages(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(search_engine): Extension<Arc<SearchEngine>>, Extension(search_engine): Extension<Arc<SearchEngine>>,
Query(params): Query<SearchParams>, Query(params): Query<SearchParams>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Validate that `q` is provided and non-empty.
let query_text = params.q.as_deref().unwrap_or("").trim().to_string(); let query_text = params.q.as_deref().unwrap_or("").trim().to_string();
let has_direct_filters = params.folder.is_some() let has_direct_filters = params.folder.is_some()
@@ -336,7 +291,6 @@ pub async fn search_messages(
let requested_limit = params.limit.clamp(1, 10_000); let requested_limit = params.limit.clamp(1, 10_000);
let search_offset = params.offset; let search_offset = params.offset;
// Parse structured operators from the query string.
let parsed = parse_search_query(&query_text); let parsed = parse_search_query(&query_text);
let folder = params.folder.or(parsed.folder); let folder = params.folder.or(parsed.folder);
@@ -349,7 +303,6 @@ pub async fn search_messages(
let unread = params.unread.or(parsed.unread); let unread = params.unread.or(parsed.unread);
let flagged = params.flagged.or(parsed.flagged); let flagged = params.flagged.or(parsed.flagged);
// Run SQLite queries in spawn_blocking to avoid blocking the Tokio executor.
let data_dir = config.data_dir.clone(); let data_dir = config.data_dir.clone();
let user_hash = session.user_hash.clone(); let user_hash = session.user_hash.clone();
let text_clone = parsed.text.clone(); let text_clone = parsed.text.clone();
@@ -385,7 +338,6 @@ pub async fn search_messages(
.map_err(|e| AppError::InternalError(format!("Task error: {e}")))? .map_err(|e| AppError::InternalError(format!("Task error: {e}")))?
.map_err(|e| AppError::InternalError(format!("Search error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Search error: {e}")))?;
// Collect SQLite results as SearchResultItems.
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
let mut results: Vec<SearchResultItem> = sqlite_results let mut results: Vec<SearchResultItem> = sqlite_results
.into_iter() .into_iter()
@@ -407,8 +359,6 @@ pub async fn search_messages(
}) })
.collect(); .collect();
// Secondary search: Tantivy for body text matches not found by SQLite.
// Only add results up to the limit cap.
if !parsed.text.is_empty() if !parsed.text.is_empty()
&& results.len() < limit && results.len() < limit
&& let Ok(user_index) = search_engine.open_user_index(&session.user_hash) && let Ok(user_index) = search_engine.open_user_index(&session.user_hash)
@@ -425,7 +375,6 @@ pub async fn search_messages(
offset: if select_all { 0 } else { search_offset }, offset: if select_all { 0 } else { search_offset },
}) })
{ {
// Resolve Tantivy UIDs via SQLite in spawn_blocking.
let data_dir2 = config.data_dir.clone(); let data_dir2 = config.data_dir.clone();
let user_hash2 = session.user_hash.clone(); let user_hash2 = session.user_hash.clone();
let remaining = limit.saturating_sub(results.len()); let remaining = limit.saturating_sub(results.len());
@@ -480,7 +429,6 @@ pub async fn search_messages(
} }
} }
// Sort results by date.
let sort_asc = sort_order == "date_asc"; let sort_asc = sort_order == "date_asc";
results.sort_by(|a, b| { results.sort_by(|a, b| {
let da = crate::db::messages::parse_date_to_epoch_public(&a.date); let da = crate::db::messages::parse_date_to_epoch_public(&a.date);
@@ -492,7 +440,6 @@ pub async fn search_messages(
} }
}); });
// Cap to requested limit after sorting.
results.truncate(limit); results.truncate(limit);
Ok(Json(SearchResponse { Ok(Json(SearchResponse {
@@ -566,10 +513,6 @@ pub async fn delete_saved_search(
} }
} }
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -628,7 +571,7 @@ mod tests {
assert_eq!(p.text, "news"); assert_eq!(p.text, "news");
assert!(p.date_from.is_some()); assert!(p.date_from.is_some());
assert!(p.date_to.is_some()); assert!(p.date_to.is_some());
// 2024-01-15 00:00:00 UTC = 1705276800
assert_eq!(p.date_from, Some(1_705_276_800)); assert_eq!(p.date_from, Some(1_705_276_800));
assert_eq!(p.date_to, Some(1_705_276_800 + 86_399)); assert_eq!(p.date_to, Some(1_705_276_800 + 86_399));
} }
@@ -637,9 +580,9 @@ mod tests {
fn parse_after_before() { fn parse_after_before() {
let p = parse_search_query("after:2024-01-01 before:2024-06-01 hello"); let p = parse_search_query("after:2024-01-01 before:2024-06-01 hello");
assert_eq!(p.text, "hello"); assert_eq!(p.text, "hello");
// 2024-01-01 = 1704067200
assert_eq!(p.date_from, Some(1_704_067_200)); assert_eq!(p.date_from, Some(1_704_067_200));
// 2024-06-01 = 1717200000
assert_eq!(p.date_to, Some(1_717_200_000)); assert_eq!(p.date_to, Some(1_717_200_000));
} }
@@ -688,7 +631,6 @@ mod tests {
#[test] #[test]
fn parse_empty_value_not_operator() { fn parse_empty_value_not_operator() {
// "from:" with no value should not be treated as an operator
let p = parse_search_query("hello from:"); let p = parse_search_query("hello from:");
assert_eq!(p.text, "hello from:"); assert_eq!(p.text, "hello from:");
assert!(p.from.is_none()); assert!(p.from.is_none());
@@ -696,9 +638,8 @@ mod tests {
#[test] #[test]
fn date_parsing_basic() { fn date_parsing_basic() {
// 2024-01-01 00:00:00 UTC
assert_eq!(parse_date_start("2024-01-01"), Some(1_704_067_200)); assert_eq!(parse_date_start("2024-01-01"), Some(1_704_067_200));
// Invalid date
assert_eq!(parse_date_start("not-a-date"), None); assert_eq!(parse_date_start("not-a-date"), None);
assert_eq!(parse_date_start("2024-13-01"), None); assert_eq!(parse_date_start("2024-13-01"), None);
} }
+10 -39
View File
@@ -26,29 +26,26 @@ pub struct SendRequest {
pub html_body: Option<String>, pub html_body: Option<String>,
pub in_reply_to: Option<String>, pub in_reply_to: Option<String>,
pub references: Option<String>, pub references: Option<String>,
/// If sending from a draft, include the draft ID to load attachments
/// and clean up after send.
pub draft_id: Option<String>, pub draft_id: Option<String>,
/// Optional identity ID to use as the From address.
/// If not provided, falls back to the session email.
pub from_identity_id: Option<i64>, pub from_identity_id: Option<i64>,
/// Request an MDN/read receipt at the selected identity/account address.
#[serde(default)] #[serde(default)]
pub request_mdn: bool, pub request_mdn: bool,
/// Message priority: high, normal, or low.
#[serde(default = "default_priority")] #[serde(default = "default_priority")]
pub priority: String, pub priority: String,
/// OpenPGP mode: none, sign, encrypt, or sign-encrypt. When omitted,
/// identity defaults are used.
pub pgp_mode: Option<String>, pub pgp_mode: Option<String>,
/// Override identity default for legacy inline OpenPGP. PGP/MIME is the default.
pub pgp_inline: Option<bool>, pub pgp_inline: Option<bool>,
/// Passphrase for the selected private key. Never persisted.
pub pgp_passphrase: Option<String>, pub pgp_passphrase: Option<String>,
/// Attach the sender's public OpenPGP key as application/pgp-keys.
#[serde(default)] #[serde(default)]
pub pgp_attach_public_key: bool, pub pgp_attach_public_key: bool,
/// Attach public OpenPGP keys for visible To/Cc recipients. Bcc is intentionally excluded.
#[serde(default)] #[serde(default)]
pub pgp_attach_recipient_keys: bool, pub pgp_attach_recipient_keys: bool,
} }
@@ -70,10 +67,6 @@ pub struct SendResponse {
pub message_id: String, pub message_id: String,
} }
/// Handler for `POST /api/messages/send`.
///
/// Validates the request, sends the message via SMTP, and appends a copy
/// to the IMAP "Sent" folder (best-effort).
pub async fn send_message_handler( pub async fn send_message_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -85,14 +78,12 @@ pub async fn send_message_handler(
db::drafts::validate_draft_id(draft_id).map_err(AppError::BadRequest)?; db::drafts::validate_draft_id(draft_id).map_err(AppError::BadRequest)?;
} }
// Validate: at least one recipient.
if req.to.is_empty() && req.cc.is_empty() && req.bcc.is_empty() { if req.to.is_empty() && req.cc.is_empty() && req.bcc.is_empty() {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"At least one recipient is required".to_string(), "At least one recipient is required".to_string(),
)); ));
} }
// Validate: subject or body must be non-empty.
let has_subject = !req.subject.trim().is_empty(); let has_subject = !req.subject.trim().is_empty();
let has_text = req let has_text = req
.text_body .text_body
@@ -115,10 +106,6 @@ pub async fn send_message_handler(
)); ));
} }
// Build SMTP credentials from the authenticated account session.
// Server endpoints are resolved from RustBox configuration at login and stored in the session.
// Normal login falls back from an unset smtp_host to the IMAP host, but reject
// an empty endpoint defensively for stale/legacy sessions.
if session.smtp_host.trim().is_empty() { if session.smtp_host.trim().is_empty() {
return Err(AppError::ServiceUnavailable( return Err(AppError::ServiceUnavailable(
"SMTP server is not configured for this account".to_string(), "SMTP server is not configured for this account".to_string(),
@@ -133,7 +120,6 @@ pub async fn send_message_handler(
password: session.password.clone(), password: session.password.clone(),
}; };
// Resolve identity and OpenPGP options before the first network await.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let selected_identity = if let Some(identity_id) = req.from_identity_id { let selected_identity = if let Some(identity_id) = req.from_identity_id {
@@ -231,8 +217,6 @@ pub async fn send_message_handler(
)?; )?;
drop(conn); drop(conn);
// Validate every envelope/header mailbox before touching SMTP. Lettre then
// performs the actual SMTPUTF8 capability negotiation during delivery.
validate_mailbox("From", &from_address)?; validate_mailbox("From", &from_address)?;
for address in &req.to { for address in &req.to {
validate_mailbox("To", address)?; validate_mailbox("To", address)?;
@@ -250,7 +234,6 @@ pub async fn send_message_handler(
validate_mailbox("read receipt", &receipt_address)?; validate_mailbox("read receipt", &receipt_address)?;
} }
// Load attachments from draft if draft_id is provided.
let mut attachments = if let Some(ref draft_id) = req.draft_id { let mut attachments = if let Some(ref draft_id) = req.draft_id {
load_draft_attachments(&config.data_dir, &session.user_hash, draft_id)? load_draft_attachments(&config.data_dir, &session.user_hash, draft_id)?
} else { } else {
@@ -283,7 +266,6 @@ pub async fn send_message_handler(
}); });
} }
// Build the sendable message.
let message = SendableMessage { let message = SendableMessage {
from: from_address, from: from_address,
to: req.to, to: req.to,
@@ -306,8 +288,6 @@ pub async fn send_message_handler(
max_message_size_bytes: config.max_message_size_bytes(), max_message_size_bytes: config.max_message_size_bytes(),
}; };
// Send via SMTP. The exact RFC822 bytes returned here are also appended
// to IMAP Sent so PGP/MIME signatures and encryption remain identical.
let sent = smtp_client let sent = smtp_client
.send_message(&smtp_creds, &message) .send_message(&smtp_creds, &message)
.await .await
@@ -323,8 +303,6 @@ pub async fn send_message_handler(
})?; })?;
let message_id = sent.message_id.clone(); let message_id = sent.message_id.clone();
// Best-effort: append a copy to the "Sent" folder via the account's IMAP server.
// Don't fail the SMTP send if this step fails.
let imap_creds = ImapCredentials { let imap_creds = ImapCredentials {
host: session.imap_host.clone(), host: session.imap_host.clone(),
port: session.imap_port, port: session.imap_port,
@@ -364,8 +342,6 @@ pub async fn send_message_handler(
} }
} }
// Clean up the remote Drafts snapshot only after SMTP delivery succeeds.
// The UID is verified against the stored UIDVALIDITY before deletion.
if let Some(ref draft_id) = req.draft_id { if let Some(ref draft_id) = req.draft_id {
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash) if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash)
&& let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id) && let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id)
@@ -387,7 +363,6 @@ pub async fn send_message_handler(
.into_response()) .into_response())
} }
/// Load attachment data from disk for a given draft.
fn load_draft_attachments( fn load_draft_attachments(
data_dir: &str, data_dir: &str,
user_hash: &str, user_hash: &str,
@@ -417,16 +392,13 @@ fn load_draft_attachments(
Ok(attachments) Ok(attachments)
} }
/// Clean up draft record and attachment files from disk after successful send.
async fn cleanup_draft(data_dir: &str, user_hash: &str, draft_id: &str) { async fn cleanup_draft(data_dir: &str, user_hash: &str, draft_id: &str) {
// Delete draft from DB (cascade deletes attachment records).
if let Ok(conn) = db::pool::open_user_db(data_dir, user_hash) if let Ok(conn) = db::pool::open_user_db(data_dir, user_hash)
&& let Err(e) = db::drafts::delete_draft(&conn, draft_id) && let Err(e) = db::drafts::delete_draft(&conn, draft_id)
{ {
tracing::warn!(error = %e, "Failed to delete draft after send"); tracing::warn!(error = %e, "Failed to delete draft after send");
} }
// Remove the attachment directory from disk.
let att_dir = Path::new(data_dir) let att_dir = Path::new(data_dir)
.join(user_hash) .join(user_hash)
.join("attachments") .join("attachments")
@@ -581,7 +553,6 @@ fn build_pgp_send_config(
} }
} }
// Include the sender's public key when available so the Sent copy can be decrypted later.
if let Some(key) = db::pgp::find_key_internal_by_email(conn, sender_email) if let Some(key) = db::pgp::find_key_internal_by_email(conn, sender_email)
.map_err(AppError::InternalError)? .map_err(AppError::InternalError)?
{ {
@@ -591,7 +562,7 @@ fn build_pgp_send_config(
} }
} }
let _ = (data_dir, user_hash); // kept in the signature for future key-agent storage without API churn let _ = (data_dir, user_hash);
Ok(Some(PgpSendConfig { Ok(Some(PgpSendConfig {
mode, mode,
inline, inline,
-9
View File
@@ -98,7 +98,6 @@ async fn connect(
.map_err(map_sieve_error) .map_err(map_sieve_error)
} }
/// GET /api/sieve/status
pub async fn status( pub async fn status(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -122,7 +121,6 @@ pub async fn status(
.into_response()) .into_response())
} }
/// GET /api/sieve/scripts
pub async fn list_scripts( pub async fn list_scripts(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -133,7 +131,6 @@ pub async fn list_scripts(
Ok(Json(ScriptsResponse { scripts }).into_response()) Ok(Json(ScriptsResponse { scripts }).into_response())
} }
/// GET /api/sieve/scripts/:name
pub async fn get_script( pub async fn get_script(
Path(name): Path<String>, Path(name): Path<String>,
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
@@ -145,7 +142,6 @@ pub async fn get_script(
Ok(Json(ScriptResponse { name, content }).into_response()) Ok(Json(ScriptResponse { name, content }).into_response())
} }
/// POST /api/sieve/check
pub async fn check_script( pub async fn check_script(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -171,7 +167,6 @@ pub async fn check_script(
Ok(Json(response).into_response()) Ok(Json(response).into_response())
} }
/// PUT /api/sieve/scripts/:name
pub async fn put_script( pub async fn put_script(
Path(name): Path<String>, Path(name): Path<String>,
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
@@ -187,7 +182,6 @@ pub async fn put_script(
Ok(Json(ActionResponse { ok: true, warning }).into_response()) Ok(Json(ActionResponse { ok: true, warning }).into_response())
} }
/// POST /api/sieve/scripts/:name/activate
pub async fn activate_script( pub async fn activate_script(
Path(name): Path<String>, Path(name): Path<String>,
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
@@ -206,7 +200,6 @@ pub async fn activate_script(
.into_response()) .into_response())
} }
/// POST /api/sieve/deactivate
pub async fn deactivate_script( pub async fn deactivate_script(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -224,7 +217,6 @@ pub async fn deactivate_script(
.into_response()) .into_response())
} }
/// DELETE /api/sieve/scripts/:name
pub async fn delete_script( pub async fn delete_script(
Path(name): Path<String>, Path(name): Path<String>,
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
@@ -240,7 +232,6 @@ pub async fn delete_script(
.into_response()) .into_response())
} }
/// POST /api/sieve/scripts/:name/rename
pub async fn rename_script( pub async fn rename_script(
Path(name): Path<String>, Path(name): Path<String>,
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
-32
View File
@@ -14,10 +14,6 @@ use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials}; use crate::imap::client::{ImapClient, ImapCredentials};
use crate::routes::messages::types::{MessageSummary, default_per_page}; use crate::routes::messages::types::{MessageSummary, default_per_page};
// ---------------------------------------------------------------------------
// Request / response types
// ---------------------------------------------------------------------------
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateTagBody { pub struct CreateTagBody {
pub name: String, pub name: String,
@@ -82,11 +78,6 @@ pub struct ReconcileTagsResponse {
pub skipped_folders: usize, pub skipped_folders: usize,
} }
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Build a `MessageSummary` from a cached message and its tags.
fn build_summary(m: db::messages::CachedMessage, tags: Vec<MessageTag>) -> MessageSummary { fn build_summary(m: db::messages::CachedMessage, tags: Vec<MessageTag>) -> MessageSummary {
MessageSummary { MessageSummary {
uid: m.uid, uid: m.uid,
@@ -167,11 +158,6 @@ fn validate_tag_color(color: &str) -> Result<(), AppError> {
} }
} }
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `GET /api/tags`
pub async fn list_tags_handler( pub async fn list_tags_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -185,7 +171,6 @@ pub async fn list_tags_handler(
Ok(Json(ListTagsResponse { tags }).into_response()) Ok(Json(ListTagsResponse { tags }).into_response())
} }
/// `POST /api/tags`
pub async fn create_tag_handler( pub async fn create_tag_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -209,7 +194,6 @@ pub async fn create_tag_handler(
Ok(Json(serde_json::json!({ "id": id, "name": name, "color": color })).into_response()) Ok(Json(serde_json::json!({ "id": id, "name": name, "color": color })).into_response())
} }
/// `PUT /api/tags/{id}`
pub async fn update_tag_handler( pub async fn update_tag_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -238,15 +222,12 @@ pub async fn update_tag_handler(
} }
} }
/// `DELETE /api/tags/{id}`
pub async fn delete_tag_handler( pub async fn delete_tag_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>, Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Phase 1: read everything needed from SQLite, then drop the connection
// before the first network await so the Axum handler future stays Send.
let (tag, refs) = { let (tag, refs) = {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -261,7 +242,6 @@ pub async fn delete_tag_handler(
(tag, refs) (tag, refs)
}; };
// Phase 2: best-effort remote cleanup while the tag still exists locally.
let mut synced = 0usize; let mut synced = 0usize;
for (uid, folder) in &refs { for (uid, folder) in &refs {
if sync_keyword_to_imap( if sync_keyword_to_imap(
@@ -278,7 +258,6 @@ pub async fn delete_tag_handler(
} }
} }
// Phase 3: delete the local tag after the async work has finished.
let deleted = { let deleted = {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
@@ -297,7 +276,6 @@ pub async fn delete_tag_handler(
.into_response()) .into_response())
} }
/// `POST /api/tags/{id}/messages`
pub async fn tag_message_handler( pub async fn tag_message_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -333,7 +311,6 @@ pub async fn tag_message_handler(
Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response()) Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response())
} }
/// `DELETE /api/tags/{id}/messages/{folder}/{uid}`
pub async fn untag_message_handler( pub async fn untag_message_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -376,7 +353,6 @@ pub async fn untag_message_handler(
Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response()) Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response())
} }
/// `POST /api/tags/{id}/messages/bulk`
pub async fn bulk_tag_handler( pub async fn bulk_tag_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -430,11 +406,6 @@ pub async fn bulk_tag_handler(
Ok(Json(serde_json::json!({ "status": "ok", "count": body.messages.len(), "imap_keyword_sync_enabled": enabled, "imap_keywords_synced": synced })).into_response()) Ok(Json(serde_json::json!({ "status": "ok", "count": body.messages.len(), "imap_keyword_sync_enabled": enabled, "imap_keywords_synced": synced })).into_response())
} }
/// `POST /api/tags/reconcile`
///
/// Reconcile local RustBox labels against authoritative IMAP FLAGS. Folders
/// that do not advertise persistent custom-keyword support are skipped so
/// local-only labels are never removed by accident.
pub async fn reconcile_tags_handler( pub async fn reconcile_tags_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -521,7 +492,6 @@ pub async fn reconcile_tags_handler(
.into_response()) .into_response())
} }
/// `GET /api/tags/{id}/messages`
pub async fn list_tag_messages_handler( pub async fn list_tag_messages_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -536,7 +506,6 @@ pub async fn list_tag_messages_handler(
let total_count = db::tags::count_messages_by_tag(&conn, &id) let total_count = db::tags::count_messages_by_tag(&conn, &id)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Batch-fetch tags for returned messages
let message_refs: Vec<(u32, &str)> = messages let message_refs: Vec<(u32, &str)> = messages
.iter() .iter()
.map(|m| (m.uid, m.folder.as_str())) .map(|m| (m.uid, m.folder.as_str()))
@@ -563,7 +532,6 @@ pub async fn list_tag_messages_handler(
.into_response()) .into_response())
} }
/// `GET /api/messages/{folder}/{uid}/tags`
pub async fn get_message_tags_handler( pub async fn get_message_tags_handler(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
+6 -67
View File
@@ -17,7 +17,6 @@ use crate::imap::client::{
use crate::smtp::client::SmtpError; use crate::smtp::client::SmtpError;
use crate::smtp::client::mock::MockSmtpClient; use crate::smtp::client::mock::MockSmtpClient;
/// Helper: create a test AppConfig with the given static dir.
fn test_config(_static_dir: &str) -> Arc<AppConfig> { fn test_config(_static_dir: &str) -> Arc<AppConfig> {
Arc::new(AppConfig { Arc::new(AppConfig {
host: "127.0.0.1".to_string(), host: "127.0.0.1".to_string(),
@@ -42,7 +41,6 @@ fn test_config(_static_dir: &str) -> Arc<AppConfig> {
}) })
} }
/// Helper: create a test AppConfig with IMAP host configured and a custom data dir.
fn test_config_with_imap(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> { fn test_config_with_imap(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
Arc::new(AppConfig { Arc::new(AppConfig {
host: "127.0.0.1".to_string(), host: "127.0.0.1".to_string(),
@@ -67,7 +65,6 @@ fn test_config_with_imap(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
}) })
} }
/// Helper: create a test AppConfig with IMAP + SMTP hosts configured.
fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> { fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
Arc::new(AppConfig { Arc::new(AppConfig {
host: "127.0.0.1".to_string(), host: "127.0.0.1".to_string(),
@@ -92,22 +89,18 @@ fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
}) })
} }
/// Helper: create a fresh SessionStore for tests.
fn test_store() -> Arc<SessionStore> { fn test_store() -> Arc<SessionStore> {
Arc::new(SessionStore::new(Duration::from_secs(3600))) Arc::new(SessionStore::new(Duration::from_secs(3600)))
} }
/// Helper: create a default mock IMAP client.
fn test_imap_client() -> Arc<dyn ImapClient> { fn test_imap_client() -> Arc<dyn ImapClient> {
Arc::new(MockImapClient::new()) Arc::new(MockImapClient::new())
} }
/// Helper: create a default mock SMTP client.
fn test_smtp_client() -> Arc<dyn SmtpClient> { fn test_smtp_client() -> Arc<dyn SmtpClient> {
Arc::new(MockSmtpClient::new()) Arc::new(MockSmtpClient::new())
} }
/// Helper: create a temporary static directory with an index.html.
fn setup_static_dir() -> TempDir { fn setup_static_dir() -> TempDir {
let dir = TempDir::new().expect("should create temp dir"); let dir = TempDir::new().expect("should create temp dir");
fs::write( fs::write(
@@ -118,19 +111,16 @@ fn setup_static_dir() -> TempDir {
dir dir
} }
/// Helper: create a test SearchEngine backed by the given data directory.
fn test_search_engine(data_dir: &str) -> Arc<crate::search::engine::SearchEngine> { fn test_search_engine(data_dir: &str) -> Arc<crate::search::engine::SearchEngine> {
Arc::new(crate::search::engine::SearchEngine::new( Arc::new(crate::search::engine::SearchEngine::new(
std::path::PathBuf::from(data_dir), std::path::PathBuf::from(data_dir),
)) ))
} }
/// Helper: create a test EventBus.
fn test_event_bus() -> Arc<crate::realtime::events::EventBus> { fn test_event_bus() -> Arc<crate::realtime::events::EventBus> {
Arc::new(crate::realtime::events::EventBus::new()) Arc::new(crate::realtime::events::EventBus::new())
} }
/// Helper: create a test IdleManager.
fn test_idle_manager() -> Arc<crate::realtime::idle::IdleManager> { fn test_idle_manager() -> Arc<crate::realtime::idle::IdleManager> {
Arc::new(crate::realtime::idle::IdleManager::new()) Arc::new(crate::realtime::idle::IdleManager::new())
} }
@@ -159,8 +149,6 @@ fn extract_ip(extractor: &ProxyAwareIpExtractor, peer: &str, forwarded: Option<&
extractor.extract(&request).unwrap() extractor.extract(&request).unwrap()
} }
/// Helper: create a multi-account session for testing protected routes.
/// Returns (browser_id, account_id, token) for use in request headers.
fn setup_test_account(store: &SessionStore, email: &str) -> (String, String, String) { fn setup_test_account(store: &SessionStore, email: &str) -> (String, String, String) {
let browser_id = store.create_browser(); let browser_id = store.create_browser();
let (token, account_id) = store.add_account_to_browser( let (token, account_id) = store.add_account_to_browser(
@@ -178,7 +166,6 @@ fn setup_test_account(store: &SessionStore, email: &str) -> (String, String, Str
(browser_id, account_id, token) (browser_id, account_id, token)
} }
/// Helper: build auth headers for multi-account requests.
fn auth_headers(browser_id: &str, account_id: &str, token: &str) -> Vec<(&'static str, String)> { fn auth_headers(browser_id: &str, account_id: &str, token: &str) -> Vec<(&'static str, String)> {
vec![ vec![
( (
@@ -189,16 +176,10 @@ fn auth_headers(browser_id: &str, account_id: &str, token: &str) -> Vec<(&'stati
] ]
} }
/// Helper: provision a user database so that route handlers can open it.
/// Migrations are applied automatically by `open_user_db`.
fn provision_user_db(data_dir: &str, user_hash: &str) { fn provision_user_db(data_dir: &str, user_hash: &str) {
let _conn = crate::db::pool::open_user_db(data_dir, user_hash).unwrap(); let _conn = crate::db::pool::open_user_db(data_dir, user_hash).unwrap();
} }
// -----------------------------------------------------------------------
// Proxy-aware IP extraction tests
// -----------------------------------------------------------------------
#[test] #[test]
fn trusted_ipv4_proxy_uses_forwarded_client_ip() { fn trusted_ipv4_proxy_uses_forwarded_client_ip() {
let extractor = test_proxy_extractor(Some("10.0.0.0/8")); let extractor = test_proxy_extractor(Some("10.0.0.0/8"));
@@ -264,10 +245,6 @@ fn malformed_forwarded_header_falls_back_to_peer_ip() {
assert_eq!(client_ip, "10.0.0.10".parse::<IpAddr>().unwrap()); assert_eq!(client_ip, "10.0.0.10".parse::<IpAddr>().unwrap());
} }
// -----------------------------------------------------------------------
// Existing tests (updated to pass imap_client)
// -----------------------------------------------------------------------
#[tokio::test] #[tokio::test]
async fn api_health_works_with_static_fallback() { async fn api_health_works_with_static_fallback() {
let dir = setup_static_dir(); let dir = setup_static_dir();
@@ -536,7 +513,7 @@ async fn login_unreachable_imap_returns_503() {
let dir = setup_static_dir(); let dir = setup_static_dir();
let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone(); let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone();
cfg.imap_host = Some("127.0.0.1".to_string()); cfg.imap_host = Some("127.0.0.1".to_string());
cfg.imap_port = 19999; // Nothing listening here cfg.imap_port = 19999;
cfg.tls_enabled = false; cfg.tls_enabled = false;
let config = Arc::new(cfg); let config = Arc::new(cfg);
let store = test_store(); let store = test_store();
@@ -694,7 +671,6 @@ async fn logout_with_valid_session_returns_200() {
let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "logged_out"); assert_eq!(json["status"], "logged_out");
// Session should be removed from the store.
assert!(store.get(&token).is_none()); assert!(store.get(&token).is_none());
} }
@@ -730,7 +706,6 @@ async fn base_path_mounts_protected_data_routes_and_scopes_logout_cookies() {
test_idle_manager(), test_idle_manager(),
); );
// This route is outside /api/auth, where browsers default empty cookie paths.
let mut request = Request::builder().uri(format!("{prefix}/api/folders")); let mut request = Request::builder().uri(format!("{prefix}/api/folders"));
for (name, value) in auth_headers(&browser_id, &account_id, &token) { for (name, value) in auth_headers(&browser_id, &account_id, &token) {
request = request.header(name, value); request = request.header(name, value);
@@ -795,15 +770,11 @@ async fn logout_clears_cookie() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let set_cookie = response.headers().get_all("set-cookie"); let set_cookie = response.headers().get_all("set-cookie");
// Should have clearing cookies for both browser and account session
let cookie_str = format!("{:?}", set_cookie); let cookie_str = format!("{:?}", set_cookie);
assert!(cookie_str.contains("rustbox_browser=;") || cookie_str.contains("Max-Age=0")); assert!(cookie_str.contains("rustbox_browser=;") || cookie_str.contains("Max-Age=0"));
} }
// -----------------------------------------------------------------------
// New tests for folders and messages routes
// -----------------------------------------------------------------------
#[tokio::test] #[tokio::test]
async fn get_folders_returns_200_with_folder_list() { async fn get_folders_returns_200_with_folder_list() {
let static_dir = setup_static_dir(); let static_dir = setup_static_dir();
@@ -815,7 +786,6 @@ async fn get_folders_returns_200_with_folder_list() {
let store = test_store(); let store = test_store();
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
// Provision user database.
provision_user_db( provision_user_db(
data_dir.path().to_str().unwrap(), data_dir.path().to_str().unwrap(),
&crate::auth::user_data::hash_email("alice@example.com"), &crate::auth::user_data::hash_email("alice@example.com"),
@@ -1038,7 +1008,6 @@ async fn get_message_returns_sanitized_html() {
&crate::auth::user_data::hash_email("alice@example.com"), &crate::auth::user_data::hash_email("alice@example.com"),
); );
// First, we need the message header in cache (fetch_headers first).
let mock = MockImapClient::new() let mock = MockImapClient::new()
.with_headers(vec![ImapMessageHeader { .with_headers(vec![ImapMessageHeader {
uid: 42, uid: 42,
@@ -1079,7 +1048,6 @@ async fn get_message_returns_sanitized_html() {
test_idle_manager(), test_idle_manager(),
); );
// First, populate the message cache by listing messages.
let mut req1 = Request::builder() let mut req1 = Request::builder()
.uri("/api/folders/INBOX/messages") .uri("/api/folders/INBOX/messages")
.header("x-requested-with", "XMLHttpRequest"); .header("x-requested-with", "XMLHttpRequest");
@@ -1092,7 +1060,6 @@ async fn get_message_returns_sanitized_html() {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
// Now get the full message.
let app2 = create_router( let app2 = create_router(
config, config,
store, store,
@@ -1121,19 +1088,15 @@ async fn get_message_returns_sanitized_html() {
assert_eq!(json["uid"], 42); assert_eq!(json["uid"], 42);
assert_eq!(json["subject"], "Test Subject"); assert_eq!(json["subject"], "Test Subject");
// Script tag is preserved (frontend handles security via iframe sandbox).
let html = json["html"].as_str().unwrap(); let html = json["html"].as_str().unwrap();
assert!(html.contains("script")); assert!(html.contains("script"));
assert!(html.contains("<b>bold</b>")); assert!(html.contains("<b>bold</b>"));
assert!(html.contains("<p>Hello</p>")); assert!(html.contains("<p>Hello</p>"));
// Plain text should be preserved.
assert_eq!(json["text"], "Hello plain text"); assert_eq!(json["text"], "Hello plain text");
// Flags should be an array.
assert!(json["flags"].is_array()); assert!(json["flags"].is_array());
// to_addresses should be an array.
assert!(json["to_addresses"].is_array()); assert!(json["to_addresses"].is_array());
} }
@@ -1151,7 +1114,6 @@ async fn update_flags_returns_200() {
let user_hash = crate::auth::user_data::hash_email("alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com");
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
// Seed a message in the cache.
let conn = let conn =
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
@@ -1210,9 +1172,6 @@ async fn update_flags_returns_200() {
#[tokio::test] #[tokio::test]
async fn update_flags_rejects_invalid_flag_characters() { async fn update_flags_rejects_invalid_flag_characters() {
// Flag values must be IMAP atom characters (RFC 3501 §9). A value
// containing a space — e.g. two flags accidentally joined into one
// string — must be rejected with 400 before reaching the IMAP client.
let static_dir = setup_static_dir(); let static_dir = setup_static_dir();
let data_dir = TempDir::new().unwrap(); let data_dir = TempDir::new().unwrap();
let config = test_config_with_imap( let config = test_config_with_imap(
@@ -1251,8 +1210,7 @@ async fn update_flags_rejects_invalid_flag_characters() {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// BAD_REQUEST confirms rejection at the validation layer — the IMAP
// error path returns SERVICE_UNAVAILABLE (503), not 400.
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"], "BAD_REQUEST"); assert_eq!(json["error"]["code"], "BAD_REQUEST");
@@ -1314,7 +1272,6 @@ async fn move_message_returns_200() {
let user_hash = crate::auth::user_data::hash_email("alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com");
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
// Seed a message in the cache.
let conn = let conn =
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
@@ -1485,7 +1442,6 @@ async fn delete_message_returns_200() {
let user_hash = crate::auth::user_data::hash_email("alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com");
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
// Seed a message in the cache.
let conn = let conn =
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap(); crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
@@ -1535,10 +1491,6 @@ async fn delete_message_returns_200() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
} }
// -----------------------------------------------------------------------
// Attachment download tests
// -----------------------------------------------------------------------
#[tokio::test] #[tokio::test]
async fn download_attachment_returns_binary_with_correct_headers() { async fn download_attachment_returns_binary_with_correct_headers() {
let static_dir = setup_static_dir(); let static_dir = setup_static_dir();
@@ -1588,7 +1540,6 @@ async fn download_attachment_returns_binary_with_correct_headers() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
// Verify content-type header.
let ct = response let ct = response
.headers() .headers()
.get("content-type") .get("content-type")
@@ -1597,7 +1548,6 @@ async fn download_attachment_returns_binary_with_correct_headers() {
.unwrap(); .unwrap();
assert_eq!(ct, "application/pdf"); assert_eq!(ct, "application/pdf");
// Verify content-disposition header.
let cd = response let cd = response
.headers() .headers()
.get("content-disposition") .get("content-disposition")
@@ -1607,7 +1557,6 @@ async fn download_attachment_returns_binary_with_correct_headers() {
assert!(cd.contains("inline")); assert!(cd.contains("inline"));
assert!(cd.contains("document.pdf")); assert!(cd.contains("document.pdf"));
// Verify body bytes match the attachment data.
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body.as_ref(), &attachment_data); assert_eq!(body.as_ref(), &attachment_data);
} }
@@ -1618,9 +1567,6 @@ async fn download_attachment_strips_control_chars_from_filename() {
("doc\r\nument.pdf", "document.pdf"), ("doc\r\nument.pdf", "document.pdf"),
("\r\n\0\u{7f}", "attachment_0"), ("\r\n\0\u{7f}", "attachment_0"),
] { ] {
// MIME decoders can surface control characters in attachment filenames
// via quoted-pair escapes. Control characters must be stripped before
// the filename is used in the Content-Disposition header.
let static_dir = setup_static_dir(); let static_dir = setup_static_dir();
let data_dir = TempDir::new().unwrap(); let data_dir = TempDir::new().unwrap();
let config = test_config_with_imap( let config = test_config_with_imap(
@@ -1672,7 +1618,7 @@ async fn download_attachment_strips_control_chars_from_filename() {
.unwrap() .unwrap()
.to_str() .to_str()
.unwrap(); .unwrap();
// Control characters stripped; the rest of the filename is intact.
assert!(cd.contains(expected_filename), "got: {cd}"); assert!(cd.contains(expected_filename), "got: {cd}");
assert!(!cd.contains('\r')); assert!(!cd.contains('\r'));
assert!(!cd.contains('\n')); assert!(!cd.contains('\n'));
@@ -1777,10 +1723,6 @@ async fn download_attachment_returns_400_for_non_numeric_id() {
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
} }
// -----------------------------------------------------------------------
// Send message endpoint tests
// -----------------------------------------------------------------------
#[tokio::test] #[tokio::test]
async fn send_returns_200_on_success() { async fn send_returns_200_on_success() {
let static_dir = setup_static_dir(); let static_dir = setup_static_dir();
@@ -2150,7 +2092,7 @@ async fn quota_without_server_limit_uses_cached_sizes() {
) )
.unwrap(); .unwrap();
drop(conn); drop(conn);
// The mock's IMAP size fallback returns zero; only SQLite has 4096.
let app = create_router( let app = create_router(
config, config,
store, store,
@@ -2178,8 +2120,6 @@ async fn export_contact_strips_control_chars_from_filename() {
("Bob\nSmith", "BobSmith.vcf"), ("Bob\nSmith", "BobSmith.vcf"),
("\r\n\0\u{7f}", "contact.vcf"), ("\r\n\0\u{7f}", "contact.vcf"),
] { ] {
// A contact name containing control characters must have them stripped
// before the name is used in the Content-Disposition header.
let static_dir = setup_static_dir(); let static_dir = setup_static_dir();
let data_dir = TempDir::new().unwrap(); let data_dir = TempDir::new().unwrap();
let config = test_config_with_imap( let config = test_config_with_imap(
@@ -2192,7 +2132,6 @@ async fn export_contact_strips_control_chars_from_filename() {
let user_hash = crate::auth::user_data::hash_email("alice@example.com"); let user_hash = crate::auth::user_data::hash_email("alice@example.com");
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
// Insert a contact whose name contains a newline.
let conn = let conn =
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
let contact = crate::db::contacts::Contact { let contact = crate::db::contacts::Contact {
@@ -2246,7 +2185,7 @@ async fn export_contact_strips_control_chars_from_filename() {
.unwrap() .unwrap()
.to_str() .to_str()
.unwrap(); .unwrap();
// Control character stripped; the rest of the name is intact.
assert!(cd.contains(expected_filename), "got: {cd}"); assert!(cd.contains(expected_filename), "got: {cd}");
assert!(!cd.contains('\n')); assert!(!cd.contains('\n'));
} }
+1 -3
View File
@@ -20,8 +20,6 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_RESPONSE_BYTES: usize = 8192; const MAX_RESPONSE_BYTES: usize = 8192;
const ONE_CLICK_BODY: &str = "List-Unsubscribe=One-Click"; const ONE_CLICK_BODY: &str = "List-Unsubscribe=One-Click";
/// `POST /api/messages/{folder}/{uid}/unsubscribe` — perform RFC 8058 one-click
/// unsubscribe when the message explicitly advertises List-Unsubscribe-Post.
pub async fn unsubscribe_message( pub async fn unsubscribe_message(
Extension(session): Extension<SessionState>, Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>, Extension(config): Extension<Arc<AppConfig>>,
@@ -230,7 +228,7 @@ async fn post_one_click_to_address(
format!("{host}:{port}") format!("{host}:{port}")
}; };
let request = format!( let request = format!(
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.4\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}", "POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.5\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}",
ONE_CLICK_BODY.len() ONE_CLICK_BODY.len()
); );
+5 -48
View File
@@ -11,7 +11,6 @@ use tantivy::schema::{
use tantivy::snippet::SnippetGenerator; use tantivy::snippet::SnippetGenerator;
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc}; use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc};
/// A message ready to be indexed into the search engine.
pub struct IndexableMessage { pub struct IndexableMessage {
pub uid: u32, pub uid: u32,
pub folder: String, pub folder: String,
@@ -24,7 +23,6 @@ pub struct IndexableMessage {
pub has_attachments: bool, pub has_attachments: bool,
} }
/// A single search result returned from a query.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct SearchResult { pub struct SearchResult {
pub uid: u32, pub uid: u32,
@@ -33,7 +31,6 @@ pub struct SearchResult {
pub snippet: String, pub snippet: String,
} }
/// Parameters for a search query, with optional filters.
#[derive(Default)] #[derive(Default)]
pub struct SearchQuery { pub struct SearchQuery {
pub text: String, pub text: String,
@@ -48,12 +45,10 @@ pub struct SearchQuery {
pub offset: usize, pub offset: usize,
} }
/// Top-level search engine that manages per-user indices.
pub struct SearchEngine { pub struct SearchEngine {
base_dir: PathBuf, base_dir: PathBuf,
} }
/// Named field handles for convenient access within the schema.
struct SchemaFields { struct SchemaFields {
uid: Field, uid: Field,
folder: Field, folder: Field,
@@ -66,7 +61,6 @@ struct SchemaFields {
has_attachments: Field, has_attachments: Field,
} }
/// A per-user Tantivy index with reader and schema.
pub struct UserIndex { pub struct UserIndex {
index: Index, index: Index,
reader: IndexReader, reader: IndexReader,
@@ -75,7 +69,6 @@ pub struct UserIndex {
fields: SchemaFields, fields: SchemaFields,
} }
/// Build the shared schema used by all user indices.
fn build_schema() -> (Schema, SchemaFields) { fn build_schema() -> (Schema, SchemaFields) {
let mut builder = Schema::builder(); let mut builder = Schema::builder();
@@ -114,12 +107,10 @@ fn build_schema() -> (Schema, SchemaFields) {
} }
impl SearchEngine { impl SearchEngine {
/// Create a new search engine with the given base directory.
pub fn new(base_dir: PathBuf) -> Self { pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir } Self { base_dir }
} }
/// Open or create the Tantivy index for a specific user.
pub fn open_user_index(&self, user_hash: &str) -> Result<UserIndex, String> { pub fn open_user_index(&self, user_hash: &str) -> Result<UserIndex, String> {
let index_dir = self.base_dir.join(user_hash).join("tantivy"); let index_dir = self.base_dir.join(user_hash).join("tantivy");
std::fs::create_dir_all(&index_dir) std::fs::create_dir_all(&index_dir)
@@ -151,8 +142,6 @@ impl SearchEngine {
} }
impl UserIndex { impl UserIndex {
/// Returns `true` if the given folder should be excluded from search indexing
/// (e.g. Spam, Junk, Trash).
pub fn is_excluded_folder(folder: &str) -> bool { pub fn is_excluded_folder(folder: &str) -> bool {
let lower = folder.to_lowercase(); let lower = folder.to_lowercase();
lower == "trash" lower == "trash"
@@ -163,7 +152,6 @@ impl UserIndex {
|| lower.ends_with("/spam") || lower.ends_with("/spam")
} }
/// Index a single message. Uses delete-before-insert for upsert semantics.
pub fn index_message(&self, msg: &IndexableMessage) -> Result<(), String> { pub fn index_message(&self, msg: &IndexableMessage) -> Result<(), String> {
let mut writer: IndexWriter = self let mut writer: IndexWriter = self
.index .index
@@ -188,14 +176,10 @@ impl UserIndex {
Ok(()) Ok(())
} }
/// Return the number of indexed documents currently visible to the reader.
pub fn document_count(&self) -> usize { pub fn document_count(&self) -> usize {
self.reader.searcher().num_docs() as usize self.reader.searcher().num_docs() as usize
} }
/// Replace the complete user index with the supplied message set.
/// Used by the diagnostics/recovery workflow after index corruption or
/// schema changes.
pub fn rebuild_from_messages(&self, messages: &[IndexableMessage]) -> Result<usize, String> { pub fn rebuild_from_messages(&self, messages: &[IndexableMessage]) -> Result<usize, String> {
let mut writer: IndexWriter = self let mut writer: IndexWriter = self
.index .index
@@ -225,7 +209,6 @@ impl UserIndex {
Ok(self.document_count()) Ok(self.document_count())
} }
/// Index multiple messages in a single batch. Uses delete-before-insert for each.
pub fn index_messages_batch(&self, messages: &[IndexableMessage]) -> Result<(), String> { pub fn index_messages_batch(&self, messages: &[IndexableMessage]) -> Result<(), String> {
let mut writer: IndexWriter = self let mut writer: IndexWriter = self
.index .index
@@ -251,7 +234,6 @@ impl UserIndex {
Ok(()) Ok(())
} }
/// Delete a message from the index by uid and folder.
#[allow(dead_code)] #[allow(dead_code)]
pub fn delete_message(&self, uid: u32, folder: &str) -> Result<(), String> { pub fn delete_message(&self, uid: u32, folder: &str) -> Result<(), String> {
let mut writer: IndexWriter = self let mut writer: IndexWriter = self
@@ -272,15 +254,11 @@ impl UserIndex {
Ok(()) Ok(())
} }
/// Search the index with a text query and optional filters.
/// Returns matching results and total count.
pub fn search(&self, query: &SearchQuery) -> Result<(Vec<SearchResult>, usize), String> { pub fn search(&self, query: &SearchQuery) -> Result<(Vec<SearchResult>, usize), String> {
let searcher = self.reader.searcher(); let searcher = self.reader.searcher();
// Collect all sub-queries into a BooleanQuery with Must clauses.
let mut clauses: Vec<(Occur, Box<dyn tantivy::query::Query>)> = Vec::new(); let mut clauses: Vec<(Occur, Box<dyn tantivy::query::Query>)> = Vec::new();
// Build the full-text query across subject, body_text, from_name, to_addresses.
if !query.text.is_empty() { if !query.text.is_empty() {
let query_parser = QueryParser::for_index( let query_parser = QueryParser::for_index(
&self.index, &self.index,
@@ -299,7 +277,6 @@ impl UserIndex {
clauses.push((Occur::Must, text_query)); clauses.push((Occur::Must, text_query));
} }
// Optional subject-only search (searches only the subject field).
if let Some(ref subject_text) = query.subject_only { if let Some(ref subject_text) = query.subject_only {
let subject_parser = QueryParser::for_index(&self.index, vec![self.fields.subject]); let subject_parser = QueryParser::for_index(&self.index, vec![self.fields.subject]);
let subject_query = subject_parser let subject_query = subject_parser
@@ -308,20 +285,16 @@ impl UserIndex {
clauses.push((Occur::Must, subject_query)); clauses.push((Occur::Must, subject_query));
} }
// If no text clauses were added, use a match-all query so filters still work.
if clauses.is_empty() { if clauses.is_empty() {
clauses.push((Occur::Must, Box::new(tantivy::query::AllQuery))); clauses.push((Occur::Must, Box::new(tantivy::query::AllQuery)));
} }
// Optional folder filter (STRING field -> TermQuery).
if let Some(ref folder) = query.folder { if let Some(ref folder) = query.folder {
let term = Term::from_field_text(self.fields.folder, folder); let term = Term::from_field_text(self.fields.folder, folder);
let folder_query = TermQuery::new(term, IndexRecordOption::Basic); let folder_query = TermQuery::new(term, IndexRecordOption::Basic);
clauses.push((Occur::Must, Box::new(folder_query))); clauses.push((Occur::Must, Box::new(folder_query)));
} }
// Optional from filter: if value contains '@', exact match on from_address;
// otherwise, text search on from_name.
if let Some(ref from) = query.from { if let Some(ref from) = query.from {
if from.contains('@') { if from.contains('@') {
let term = Term::from_field_text(self.fields.from_address, from); let term = Term::from_field_text(self.fields.from_address, from);
@@ -336,7 +309,6 @@ impl UserIndex {
} }
} }
// Optional to filter (TEXT field -> text search on to_addresses).
if let Some(ref to) = query.to { if let Some(ref to) = query.to {
let to_parser = QueryParser::for_index(&self.index, vec![self.fields.to_addresses]); let to_parser = QueryParser::for_index(&self.index, vec![self.fields.to_addresses]);
let to_query = to_parser let to_query = to_parser
@@ -345,7 +317,6 @@ impl UserIndex {
clauses.push((Occur::Must, to_query)); clauses.push((Occur::Must, to_query));
} }
// Optional date range filter.
if query.date_from.is_some() || query.date_to.is_some() { if query.date_from.is_some() || query.date_to.is_some() {
let lower = match query.date_from { let lower = match query.date_from {
Some(ts) => Bound::Included(ts), Some(ts) => Bound::Included(ts),
@@ -359,7 +330,6 @@ impl UserIndex {
clauses.push((Occur::Must, Box::new(range_query))); clauses.push((Occur::Must, Box::new(range_query)));
} }
// Optional has_attachment filter.
if let Some(has_att) = query.has_attachment { if let Some(has_att) = query.has_attachment {
let val = if has_att { 1u64 } else { 0u64 }; let val = if has_att { 1u64 } else { 0u64 };
let term = Term::from_field_u64(self.fields.has_attachments, val); let term = Term::from_field_u64(self.fields.has_attachments, val);
@@ -369,13 +339,11 @@ impl UserIndex {
let combined_query = BooleanQuery::new(clauses); let combined_query = BooleanQuery::new(clauses);
// Get total count and paginated results.
let total_limit = query.offset + query.limit; let total_limit = query.offset + query.limit;
let (total_count, top_docs) = searcher let (total_count, top_docs) = searcher
.search(&combined_query, &(Count, TopDocs::with_limit(total_limit))) .search(&combined_query, &(Count, TopDocs::with_limit(total_limit)))
.map_err(|e| format!("Search failed: {e}"))?; .map_err(|e| format!("Search failed: {e}"))?;
// Build a snippet generator for the subject field.
let mut snippet_generator = let mut snippet_generator =
SnippetGenerator::create(&searcher, &combined_query, self.fields.subject) SnippetGenerator::create(&searcher, &combined_query, self.fields.subject)
.map_err(|e| format!("Failed to create snippet generator: {e}"))?; .map_err(|e| format!("Failed to create snippet generator: {e}"))?;
@@ -416,7 +384,6 @@ impl UserIndex {
Ok((results, total_count)) Ok((results, total_count))
} }
/// Delete existing documents that match the uid+folder combination.
fn delete_existing(&self, writer: &mut IndexWriter, uid: u32, folder: &str) { fn delete_existing(&self, writer: &mut IndexWriter, uid: u32, folder: &str) {
let uid_term = Term::from_field_u64(self.fields.uid, uid as u64); let uid_term = Term::from_field_u64(self.fields.uid, uid as u64);
let folder_term = Term::from_field_text(self.fields.folder, folder); let folder_term = Term::from_field_text(self.fields.folder, folder);
@@ -432,7 +399,6 @@ impl UserIndex {
let _ = writer.delete_query(Box::new(delete_query)); let _ = writer.delete_query(Box::new(delete_query));
} }
/// Build a Tantivy document from an IndexableMessage.
fn build_document(&self, msg: &IndexableMessage) -> TantivyDocument { fn build_document(&self, msg: &IndexableMessage) -> TantivyDocument {
doc!( doc!(
self.fields.uid => msg.uid as u64, self.fields.uid => msg.uid as u64,
@@ -453,7 +419,6 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
/// Helper: create a SearchEngine + UserIndex in a temp directory.
fn setup() -> (TempDir, UserIndex) { fn setup() -> (TempDir, UserIndex) {
let tmp = TempDir::new().expect("Failed to create temp dir"); let tmp = TempDir::new().expect("Failed to create temp dir");
let engine = SearchEngine::new(tmp.path().to_path_buf()); let engine = SearchEngine::new(tmp.path().to_path_buf());
@@ -535,7 +500,6 @@ mod tests {
idx.index_messages_batch(&[msg1, msg2]).unwrap(); idx.index_messages_batch(&[msg1, msg2]).unwrap();
// Search with folder filter for "Sent" only.
let query = SearchQuery { let query = SearchQuery {
text: "project".to_string(), text: "project".to_string(),
folder: Some("Sent".to_string()), folder: Some("Sent".to_string()),
@@ -555,21 +519,20 @@ mod tests {
let (_tmp, idx) = setup(); let (_tmp, idx) = setup();
let mut msg1 = make_message(1, "INBOX", "January news", "News from January."); let mut msg1 = make_message(1, "INBOX", "January news", "News from January.");
msg1.date_epoch = 1704067200; // 2024-01-01 msg1.date_epoch = 1704067200;
let mut msg2 = make_message(2, "INBOX", "March news", "News from March."); let mut msg2 = make_message(2, "INBOX", "March news", "News from March.");
msg2.date_epoch = 1709251200; // 2024-03-01 msg2.date_epoch = 1709251200;
let mut msg3 = make_message(3, "INBOX", "June news", "News from June."); let mut msg3 = make_message(3, "INBOX", "June news", "News from June.");
msg3.date_epoch = 1717200000; // 2024-06-01 msg3.date_epoch = 1717200000;
idx.index_messages_batch(&[msg1, msg2, msg3]).unwrap(); idx.index_messages_batch(&[msg1, msg2, msg3]).unwrap();
// Search for "news" within February to April.
let query = SearchQuery { let query = SearchQuery {
text: "news".to_string(), text: "news".to_string(),
date_from: Some(1706745600), // 2024-02-01 date_from: Some(1706745600),
date_to: Some(1714521600), // 2024-05-01 date_to: Some(1714521600),
limit: 10, limit: 10,
..Default::default() ..Default::default()
}; };
@@ -589,7 +552,6 @@ mod tests {
idx.index_messages_batch(&[msg1, msg2]).unwrap(); idx.index_messages_batch(&[msg1, msg2]).unwrap();
// Verify both are found.
let query = SearchQuery { let query = SearchQuery {
text: "important".to_string(), text: "important".to_string(),
limit: 10, limit: 10,
@@ -598,7 +560,6 @@ mod tests {
let (_, total) = idx.search(&query).unwrap(); let (_, total) = idx.search(&query).unwrap();
assert_eq!(total, 2); assert_eq!(total, 2);
// Delete message 1.
idx.delete_message(1, "INBOX").unwrap(); idx.delete_message(1, "INBOX").unwrap();
let (results, total) = idx.search(&query).unwrap(); let (results, total) = idx.search(&query).unwrap();
@@ -614,7 +575,6 @@ mod tests {
let msg = make_message(1, "INBOX", "Test subject", "Test body text."); let msg = make_message(1, "INBOX", "Test subject", "Test body text.");
idx.index_message(&msg).unwrap(); idx.index_message(&msg).unwrap();
// Empty text with no filters uses AllQuery — returns everything.
let query = SearchQuery { let query = SearchQuery {
text: "".to_string(), text: "".to_string(),
limit: 10, limit: 10,
@@ -643,7 +603,6 @@ mod tests {
idx.index_messages_batch(&messages).unwrap(); idx.index_messages_batch(&messages).unwrap();
// Page 1: limit=2, offset=0.
let query_page1 = SearchQuery { let query_page1 = SearchQuery {
text: "report".to_string(), text: "report".to_string(),
limit: 2, limit: 2,
@@ -654,7 +613,6 @@ mod tests {
assert_eq!(total1, 5); assert_eq!(total1, 5);
assert_eq!(results1.len(), 2); assert_eq!(results1.len(), 2);
// Page 2: limit=2, offset=2.
let query_page2 = SearchQuery { let query_page2 = SearchQuery {
text: "report".to_string(), text: "report".to_string(),
limit: 2, limit: 2,
@@ -665,7 +623,6 @@ mod tests {
assert_eq!(total2, 5); assert_eq!(total2, 5);
assert_eq!(results2.len(), 2); assert_eq!(results2.len(), 2);
// Results from page 1 and page 2 should be different.
let uids1: Vec<u32> = results1.iter().map(|r| r.uid).collect(); let uids1: Vec<u32> = results1.iter().map(|r| r.uid).collect();
let uids2: Vec<u32> = results2.iter().map(|r| r.uid).collect(); let uids2: Vec<u32> = results2.iter().map(|r| r.uid).collect();
for uid in &uids2 { for uid in &uids2 {
+3 -10
View File
@@ -19,7 +19,7 @@ pub const MAX_SCRIPT_BYTES: usize = 1024 * 1024;
pub struct SieveCredentials { pub struct SieveCredentials {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
/// `starttls`, `implicit`, or `none`.
pub tls_mode: String, pub tls_mode: String,
pub allow_insecure_auth: bool, pub allow_insecure_auth: bool,
pub username: String, pub username: String,
@@ -84,16 +84,12 @@ impl ManageSieveClient {
let reply = read_response(&mut tcp, creds.timeout).await?; let reply = read_response(&mut tcp, creds.timeout).await?;
ensure_ok(&reply)?; ensure_ok(&reply)?;
// Do not buffer beyond the STARTTLS response. read_response() consumes
// exactly one protocol line at a time, preventing plaintext bytes from
// being replayed into the TLS stream.
let tls = async_native_tls::TlsConnector::new(); let tls = async_native_tls::TlsConnector::new();
let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp)) let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp))
.await .await
.map_err(|_| SieveError::Timeout)? .map_err(|_| SieveError::Timeout)?
.map_err(|e| SieveError::TlsFailed(e.to_string()))?; .map_err(|e| SieveError::TlsFailed(e.to_string()))?;
// RFC 5804 requires the server to re-issue capabilities after TLS.
let greeting = read_response(&mut tls_stream, creds.timeout).await?; let greeting = read_response(&mut tls_stream, creds.timeout).await?;
(SieveStream::Tls(tls_stream), greeting) (SieveStream::Tls(tls_stream), greeting)
} }
@@ -212,7 +208,6 @@ impl ManageSieveClient {
return Err(SieveError::ScriptTooLarge); return Err(SieveError::ScriptTooLarge);
} }
// RFC 5804 recommends HAVESPACE before PUTSCRIPT for larger scripts.
let have_space = format!("HAVESPACE {} {}\r\n", quote(name)?, script.len()); let have_space = format!("HAVESPACE {} {}\r\n", quote(name)?, script.len());
let response = self.command(&have_space).await?; let response = self.command(&have_space).await?;
ensure_ok(&response)?; ensure_ok(&response)?;
@@ -362,7 +357,7 @@ impl ProtocolResponse {
} }
let text = format!("{}{}", self.final_line.before, self.final_line.after); let text = format!("{}{}", self.final_line.before, self.final_line.after);
// Prefer a quoted human-readable string, falling back to the full status line.
if let Some(pos) = text.rfind('"') { if let Some(pos) = text.rfind('"') {
let prefix = &text[..pos]; let prefix = &text[..pos];
if let Some(start) = prefix.rfind('"') { if let Some(start) = prefix.rfind('"') {
@@ -436,8 +431,6 @@ async fn read_wire_line<S: AsyncRead + Unpin>(stream: &mut S) -> Result<WireLine
.await .await
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?; .map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
// A literal is a token inside the response line. The bytes following it,
// including an optional ` ACTIVE`, continue until the response CRLF.
let after_bytes = read_crlf_line(stream).await?; let after_bytes = read_crlf_line(stream).await?;
let after = String::from_utf8(after_bytes) let after = String::from_utf8(after_bytes)
.map_err(|_| SieveError::Protocol("literal suffix is not UTF-8".into()))?; .map_err(|_| SieveError::Protocol("literal suffix is not UTF-8".into()))?;
@@ -477,7 +470,7 @@ fn trailing_literal_size(line: &str) -> Result<Option<usize>, SieveError> {
return Ok(None); return Ok(None);
} }
let raw = &trimmed[open + 1..trimmed.len() - 1]; let raw = &trimmed[open + 1..trimmed.len() - 1];
// Server-to-client literals do not use the non-synchronizing + form.
if raw.ends_with('+') { if raw.ends_with('+') {
return Err(SieveError::Protocol( return Err(SieveError::Protocol(
"server sent an invalid non-synchronizing literal".into(), "server sent an invalid non-synchronizing literal".into(),
+1 -1
View File
@@ -29,7 +29,7 @@ impl fmt::Display for SieveError {
write!(f, "ManageSieve server rejected the command: {msg}") write!(f, "ManageSieve server rejected the command: {msg}")
} }
Self::Timeout => write!(f, "ManageSieve operation timed out"), Self::Timeout => write!(f, "ManageSieve operation timed out"),
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.4 size limit"), Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.5 size limit"),
} }
} }
} }
-2
View File
@@ -1,5 +1,3 @@
//! Shared RFC822/MIME builder used by SMTP delivery and IMAP Sent copies.
use lettre::message::{ use lettre::message::{
Attachment, Mailbox, MessageBuilder, MultiPart, SinglePart, header::ContentType, Attachment, Mailbox, MessageBuilder, MultiPart, SinglePart, header::ContentType,
}; };
-28
View File
@@ -1,46 +1,22 @@
//! SMTP client abstraction and real implementation.
use async_trait::async_trait; use async_trait::async_trait;
// Import and re-export types for backward compatibility
pub use crate::smtp::error::SmtpError; pub use crate::smtp::error::SmtpError;
pub use crate::smtp::types::{AttachmentData, SendableMessage, SmtpCredentials, SmtpSendResult}; pub use crate::smtp::types::{AttachmentData, SendableMessage, SmtpCredentials, SmtpSendResult};
// ---------------------------------------------------------------------------
// Trait definition
// ---------------------------------------------------------------------------
/// Abstraction over SMTP operations.
///
/// Every method receives explicit connection parameters so that the trait
/// remains stateless — no persistent connections are held.
///
/// The `Send + Sync` bounds allow implementations to be shared across
/// Tokio tasks and stored in `Arc`.
#[allow(clippy::double_must_use)] #[allow(clippy::double_must_use)]
#[async_trait] #[async_trait]
pub trait SmtpClient: Send + Sync { pub trait SmtpClient: Send + Sync {
/// Send an email message. Returns the generated Message-ID and exact RFC822 bytes.
async fn send_message( async fn send_message(
&self, &self,
creds: &SmtpCredentials, creds: &SmtpCredentials,
message: &SendableMessage, message: &SendableMessage,
) -> Result<SmtpSendResult, SmtpError>; ) -> Result<SmtpSendResult, SmtpError>;
/// Verify that the configured SMTP endpoint accepts an authenticated NOOP.
async fn check_connection(&self, _creds: &SmtpCredentials) -> Result<(), SmtpError> { async fn check_connection(&self, _creds: &SmtpCredentials) -> Result<(), SmtpError> {
Ok(()) Ok(())
} }
} }
// ---------------------------------------------------------------------------
// Real implementation backed by lettre
// ---------------------------------------------------------------------------
/// Production SMTP client that uses `lettre`.
///
/// This is a stateless unit struct — every method creates a fresh connection,
/// performs the operation, and disconnects.
pub struct RealSmtpClient; pub struct RealSmtpClient;
fn smtp_transport( fn smtp_transport(
@@ -142,10 +118,6 @@ impl SmtpClient for RealSmtpClient {
} }
} }
// ---------------------------------------------------------------------------
// Mock implementation (test-only)
// ---------------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
#[path = "mock.rs"] #[path = "mock.rs"]
pub mod mock; pub mod mock;
+3 -7
View File
@@ -1,18 +1,14 @@
//! SMTP error types.
use std::fmt; use std::fmt;
/// Errors that can occur during SMTP operations.
#[derive(Debug)] #[derive(Debug)]
#[allow(clippy::enum_variant_names)] #[allow(clippy::enum_variant_names)]
pub enum SmtpError { pub enum SmtpError {
/// Could not connect to the SMTP server.
ConnectionFailed(String), ConnectionFailed(String),
/// The server rejected our credentials.
AuthenticationFailed, AuthenticationFailed,
/// The message could not be sent.
SendFailed(String), SendFailed(String),
/// The fully rendered RFC822/MIME payload exceeds the configured MTA limit.
MessageTooLarge { actual: usize, limit: usize }, MessageTooLarge { actual: usize, limit: usize },
} }
+1 -21
View File
@@ -1,23 +1,15 @@
//! Mock SMTP client for testing.
use super::{ use super::{
AttachmentData, SendableMessage, SmtpClient, SmtpCredentials, SmtpError, SmtpSendResult, AttachmentData, SendableMessage, SmtpClient, SmtpCredentials, SmtpError, SmtpSendResult,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Mutex; use std::sync::Mutex;
/// A mock SMTP client that records sent messages.
///
/// Uses interior mutability (`Mutex`) so it can be shared behind `&self`.
/// Build it up with the `.with_*()` builder methods, then pass it wherever
/// an `&dyn SmtpClient` is needed.
pub struct MockSmtpClient { pub struct MockSmtpClient {
should_fail: Mutex<Option<SmtpError>>, should_fail: Mutex<Option<SmtpError>>,
sent_messages: Mutex<Vec<SendableMessage>>, sent_messages: Mutex<Vec<SendableMessage>>,
} }
impl MockSmtpClient { impl MockSmtpClient {
/// Create a new empty mock.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
should_fail: Mutex::new(None), should_fail: Mutex::new(None),
@@ -25,18 +17,15 @@ impl MockSmtpClient {
} }
} }
/// Make every subsequent call return this error.
pub fn with_error(self, error: SmtpError) -> Self { pub fn with_error(self, error: SmtpError) -> Self {
*self.should_fail.lock().unwrap() = Some(error); *self.should_fail.lock().unwrap() = Some(error);
self self
} }
/// Return the number of messages sent through this mock.
pub fn sent_count(&self) -> usize { pub fn sent_count(&self) -> usize {
self.sent_messages.lock().unwrap().len() self.sent_messages.lock().unwrap().len()
} }
/// Return a clone of the most recently sent message, if any.
pub fn last_sent(&self) -> Option<SendableMessage> { pub fn last_sent(&self) -> Option<SendableMessage> {
self.sent_messages.lock().unwrap().last().cloned() self.sent_messages.lock().unwrap().last().cloned()
} }
@@ -48,8 +37,6 @@ impl Default for MockSmtpClient {
} }
} }
/// Helper to clone an `SmtpError` for the mock (the real errors are not
/// `Clone`, so we reconstruct them).
fn clone_error(err: &SmtpError) -> SmtpError { fn clone_error(err: &SmtpError) -> SmtpError {
match err { match err {
SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()), SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()),
@@ -86,15 +73,10 @@ impl SmtpClient for MockSmtpClient {
} }
} }
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// Convenience helper to build dummy credentials for tests.
fn test_creds() -> SmtpCredentials { fn test_creds() -> SmtpCredentials {
SmtpCredentials { SmtpCredentials {
host: "smtp.example.com".to_string(), host: "smtp.example.com".to_string(),
@@ -106,7 +88,6 @@ mod tests {
} }
} }
/// Build a minimal test message.
fn test_message() -> SendableMessage { fn test_message() -> SendableMessage {
SendableMessage { SendableMessage {
from: "user@example.com".to_string(), from: "user@example.com".to_string(),
@@ -215,7 +196,6 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(matches!(err, SmtpError::AuthenticationFailed)); assert!(matches!(err, SmtpError::AuthenticationFailed));
// Ensure no messages were recorded.
assert_eq!(mock.sent_count(), 0); assert_eq!(mock.sent_count(), 0);
} }
@@ -235,7 +215,7 @@ mod tests {
let msg = test_message(); let msg = test_message();
let err = client.send_message(&creds, &msg).await.unwrap_err(); let err = client.send_message(&creds, &msg).await.unwrap_err();
// With a fake host the connection should fail.
assert!( assert!(
matches!( matches!(
err, err,
+19 -34
View File
@@ -1,7 +1,3 @@
//! SMTP data types.
/// Parameters needed to establish an SMTP connection.
/// Passed explicitly to every trait method so the trait stays stateless.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SmtpCredentials { pub struct SmtpCredentials {
pub host: String, pub host: String,
@@ -18,42 +14,40 @@ pub struct SmtpSendResult {
pub rfc822: Vec<u8>, pub rfc822: Vec<u8>,
} }
/// A message ready to be sent via SMTP.
#[derive(Clone)] #[derive(Clone)]
pub struct SendableMessage { pub struct SendableMessage {
/// Sender email address.
pub from: String, pub from: String,
/// Primary recipients.
pub to: Vec<String>, pub to: Vec<String>,
/// CC recipients.
pub cc: Vec<String>, pub cc: Vec<String>,
/// BCC recipients.
pub bcc: Vec<String>, pub bcc: Vec<String>,
/// Optional Reply-To mailbox.
pub reply_to: Option<String>, pub reply_to: Option<String>,
/// Optional organization label from the selected identity.
pub organization: Option<String>, pub organization: Option<String>,
/// Subject line.
pub subject: String, pub subject: String,
/// Plain-text body.
pub text_body: String, pub text_body: String,
/// Optional HTML body.
pub html_body: Option<String>, pub html_body: Option<String>,
/// In-Reply-To header value for threading.
pub in_reply_to: Option<String>, pub in_reply_to: Option<String>,
/// References header value for threading.
pub references: Option<String>, pub references: Option<String>,
/// Address that should receive an MDN/read receipt request.
pub read_receipt_to: Option<String>, pub read_receipt_to: Option<String>,
/// Message priority: `high`, `normal`, or `low`.
pub priority: String, pub priority: String,
/// Administrator-configured headers applied to every outgoing copy.
pub extra_headers: Vec<(String, String)>, pub extra_headers: Vec<(String, String)>,
/// File attachments.
pub attachments: Vec<AttachmentData>, pub attachments: Vec<AttachmentData>,
/// Optional OpenPGP transformation applied to the MIME entity.
pub pgp: Option<PgpSendConfig>, pub pgp: Option<PgpSendConfig>,
/// Hard limit for the final RFC822/MIME payload submitted to SMTP.
pub max_message_size_bytes: usize, pub max_message_size_bytes: usize,
} }
@@ -74,24 +68,17 @@ pub struct PgpSendConfig {
pub recipient_public_keys: Vec<String>, pub recipient_public_keys: Vec<String>,
} }
/// A single file attachment to include in an outgoing message.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AttachmentData { pub struct AttachmentData {
/// Filename as it should appear to the recipient.
pub filename: String, pub filename: String,
/// MIME content type (e.g. "application/pdf").
pub content_type: String, pub content_type: String,
/// Raw file content.
pub data: Vec<u8>, pub data: Vec<u8>,
/// Optional Content-ID for inline images (referenced via `cid:` in HTML).
/// When set, the attachment is treated as an inline image rather than a
/// regular file attachment.
pub content_id: Option<String>, pub content_id: Option<String>,
} }
/// Apply RustBox-managed optional headers shared by SMTP delivery and the
/// copy appended to IMAP Sent. Header names are static and values are stripped
/// of CR/LF before they reach lettre's raw header API.
pub fn apply_optional_headers( pub fn apply_optional_headers(
builder: lettre::message::MessageBuilder, builder: lettre::message::MessageBuilder,
message: &SendableMessage, message: &SendableMessage,
@@ -120,8 +107,6 @@ pub fn apply_optional_headers(
builder builder
} }
/// Apply the optional outgoing headers without requiring a full SendableMessage.
/// This is also used for RFC822 draft snapshots appended to IMAP.
pub fn apply_optional_header_values( pub fn apply_optional_header_values(
mut builder: lettre::message::MessageBuilder, mut builder: lettre::message::MessageBuilder,
read_receipt_to: Option<&str>, read_receipt_to: Option<&str>,
+20 -6
View File
@@ -64,7 +64,6 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
} }
match relative { match relative {
// Keep unversioned aliases non-cacheable for compatibility with old bookmarks/proxies.
"assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS), "assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS),
"assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS), "assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS),
"assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS), "assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS),
@@ -96,11 +95,26 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
.replace("__RUSTBOX_ASSET_ID__", ASSET_ID) .replace("__RUSTBOX_ASSET_ID__", ASSET_ID)
.replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION")) .replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION"))
.replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme) .replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme)
.replace("__RUSTBOX_DEFAULT_INTERFACE_SKIN__", &config.default_interface_skin) .replace(
.replace("__RUSTBOX_MAX_ATTACHMENT_BYTES__", &config.max_attachment_size_bytes().to_string()) "__RUSTBOX_DEFAULT_INTERFACE_SKIN__",
.replace("__RUSTBOX_MAX_ATTACHMENT_MB__", &config.max_attachment_size_mb.to_string()) &config.default_interface_skin,
.replace("__RUSTBOX_MAX_MESSAGE_BYTES__", &config.max_message_size_bytes().to_string()) )
.replace("__RUSTBOX_MAX_MESSAGE_MB__", &config.max_message_size_mb.to_string()) .replace(
"__RUSTBOX_MAX_ATTACHMENT_BYTES__",
&config.max_attachment_size_bytes().to_string(),
)
.replace(
"__RUSTBOX_MAX_ATTACHMENT_MB__",
&config.max_attachment_size_mb.to_string(),
)
.replace(
"__RUSTBOX_MAX_MESSAGE_BYTES__",
&config.max_message_size_bytes().to_string(),
)
.replace(
"__RUSTBOX_MAX_MESSAGE_MB__",
&config.max_message_size_mb.to_string(),
)
.replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim()); .replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim());
( (
StatusCode::OK, StatusCode::OK,
+1 -1
View File
@@ -194,7 +194,7 @@ html[data-color-preset="red"] {
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); --selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
} }
/* Advanced per-user interface controls. */
:root { :root {
--toolbar-density-factor: 1; --toolbar-density-factor: 1;
--sidebar-item-height: 29px; --sidebar-item-height: 29px;
+109 -5
View File
@@ -754,7 +754,7 @@
min-width: 0; min-width: 0;
} }
/* Reader toolbars must remain usable at every UI scale. */
.reader-toolbar { .reader-toolbar {
max-width: 100%; max-width: 100%;
overflow-x: auto; overflow-x: auto;
@@ -834,7 +834,7 @@
} }
} }
/* Hierarchical folder tree. The disclosure control is separate from the folder action. */
.folder-row { .folder-row {
display: grid; display: grid;
grid-template-columns: 18px minmax(0, 1fr); grid-template-columns: 18px minmax(0, 1fr);
@@ -967,7 +967,7 @@ html[data-reduce-motion="true"] #thread-toggle svg {
color: var(--text); color: var(--text);
} }
/* A flagged message stays visually quiet while remaining easy to scan. */
.message-row.flagged { .message-row.flagged {
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--danger) 58%, transparent); box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--danger) 58%, transparent);
} }
@@ -979,7 +979,7 @@ html[data-reduce-motion="true"] #thread-toggle svg {
padding: 0 1px; padding: 0 1px;
} }
/* Advanced appearance preferences. */
.folder-pane { .folder-pane {
background: var(--folder-bg); background: var(--folder-bg);
} }
@@ -1058,7 +1058,7 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
} }
/* Pane-aware anti-collision rules. These react to the pane width, not only viewport width. */
@container reader-pane (max-width: 640px) { @container reader-pane (max-width: 640px) {
.reader-toolbar { .reader-toolbar {
gap: 1px; gap: 1px;
@@ -1193,4 +1193,108 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
#message-transfer-menu .toolbar-menu-popover { #message-transfer-menu .toolbar-menu-popover {
left: auto; left: auto;
right: 0; right: 0;
}
.reader-pane.reader-fullscreen {
display: block !important;
position: fixed;
inset: 0;
z-index: 240;
width: 100vw;
height: 100dvh;
max-width: none;
background: var(--panel);
}
body.reader-fullscreen-open {
overflow: hidden;
}
.reader-pane.reader-fullscreen .reader-content {
height: 100dvh;
}
.reader-pane.reader-fullscreen .message-header,
.reader-pane.reader-fullscreen .thread-section,
.reader-pane.reader-fullscreen .attachment-list,
.reader-pane.reader-fullscreen .remote-image-bar,
.reader-pane.reader-fullscreen .message-body {
max-width: none;
}
@media (max-width: 820px) {
.reader-toolbar {
min-height: 42px;
padding-block: 4px;
}
.reader-toolbar .toolbar-menu>summary span {
display: inline;
}
.reader-toolbar .icon-button,
.reader-toolbar .button,
.reader-toolbar .toolbar-menu>summary {
min-width: 34px;
min-height: 34px;
}
.reader-toolbar .toolbar-menu>summary {
padding-inline: 7px;
}
.message-quick-action {
width: 36px;
height: 36px;
}
.message-quick-action svg {
width: 15px;
height: 15px;
}
.toolbar-menu-popover button {
min-height: 44px;
padding: 10px 12px;
}
.attachment-list {
align-items: stretch;
}
.attachment-item {
display: flex;
width: 100%;
max-width: 100%;
gap: 6px;
}
.attachment-item .attachment-button {
flex: 1 1 auto;
min-width: 0;
min-height: 44px;
justify-content: flex-start;
text-align: left;
}
.attachment-button span {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.message-header h1 {
overflow-wrap: anywhere;
word-break: break-word;
}
}
@media (max-width: 560px) {
.reader-toolbar .toolbar-menu>summary span {
display: inline;
}
.reader-toolbar .toolbar-menu>summary {
min-width: max-content;
}
} }
+1 -1
View File
@@ -611,7 +611,7 @@
min-height: 1.35em; min-height: 1.35em;
} }
/* OpenPGP composer / reader */
.pgp-reader-bar { .pgp-reader-bar {
display: flex; display: flex;
align-items: center; align-items: center;
+1 -35
View File
@@ -1824,40 +1824,6 @@
border-left-color: var(--accent); border-left-color: var(--accent);
} }
.toast-progress {
grid-template-columns: 20px minmax(0, 1fr) auto 24px;
}
.toast-progress-content {
min-width: 0;
display: grid;
gap: 6px;
}
.toast-progress-track {
display: block;
width: 100%;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: var(--panel-3);
}
.toast-progress-bar {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--accent);
transition: width .12s linear;
}
.toast-progress-value {
min-width: 34px;
color: var(--muted);
font: 10px var(--font-mono);
text-align: right;
}
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -1959,7 +1925,7 @@
} }
} }
/* Dialog/layout hardening */
.contacts-card, .contacts-card,
.calendar-card, .calendar-card,
.headers-card, .headers-card,
+4 -9
View File
@@ -1,8 +1,3 @@
/* Roundcube-compatible visual skins.
Palette values are aligned to Roundcube Classic and Elastic upstream CSS.
Classic never shipped an official dark skin; the dark block is a contrast-preserving adaptation. */
/* ---------- Roundcube Classic ---------- */
html[data-interface-skin="roundcube-classic"] { html[data-interface-skin="roundcube-classic"] {
--font-sans: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif; --font-sans: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif;
--font-mono: Monaco, "Courier New", monospace; --font-mono: Monaco, "Courier New", monospace;
@@ -193,7 +188,7 @@ html[data-interface-skin="roundcube-classic"] .message-row.active .message-state
html[data-interface-skin="roundcube-classic"] .message-row.selected .message-date, html[data-interface-skin="roundcube-classic"] .message-row.selected .message-date,
html[data-interface-skin="roundcube-classic"] .message-row.selected .message-snippet { html[data-interface-skin="roundcube-classic"] .message-row.selected .message-snippet {
color: #ffffff; color: #ffffff;
border-color: rgba(255,255,255,.8); border-color: rgba(255, 255, 255, .8);
} }
html[data-interface-skin="roundcube-classic"] .settings-tab { html[data-interface-skin="roundcube-classic"] .settings-tab {
@@ -212,7 +207,7 @@ html[data-interface-skin="roundcube-classic"] .diagnostic-card {
border-radius: 0; border-radius: 0;
} }
/* ---------- Roundcube Elastic ---------- */
html[data-interface-skin="roundcube-elastic"] { html[data-interface-skin="roundcube-elastic"] {
--font-sans: Roboto, "Segoe UI", Arial, sans-serif; --font-sans: Roboto, "Segoe UI", Arial, sans-serif;
--radius-control: .3rem; --radius-control: .3rem;
@@ -369,7 +364,7 @@ html[data-interface-skin="roundcube-elastic"] .appearance-preset:hover {
border-color: #37beff; border-color: #37beff;
} }
/* Mobile drawer follows the active skin and uses Roundcube Elastic's back affordance. */
@media (max-width: 820px) { @media (max-width: 820px) {
.folder-drawer-header.mobile-only { .folder-drawer-header.mobile-only {
display: grid; display: grid;
@@ -431,4 +426,4 @@ html[data-interface-skin="roundcube-elastic"] .appearance-preset:hover {
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .folder-drawer-header { html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .folder-drawer-header {
background: #2f3a3f; background: #2f3a3f;
} }
} }
+5 -6
View File
@@ -1,4 +1,3 @@
/* ---------- Roundcube Larry ---------- */
html[data-interface-skin="roundcube-larry"] { html[data-interface-skin="roundcube-larry"] {
--font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif; --font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif;
--radius-control: 3px; --radius-control: 3px;
@@ -101,7 +100,7 @@ html[data-interface-skin="roundcube-larry"] .search-field:focus-within {
box-shadow: 0 0 0 1px var(--accent); box-shadow: 0 0 0 1px var(--accent);
} }
/* ---------- Clean ---------- */
html[data-interface-skin="clean"][data-theme="light"] { html[data-interface-skin="clean"][data-theme="light"] {
color-scheme: light; color-scheme: light;
--bg: #f5f7fa; --bg: #f5f7fa;
@@ -177,7 +176,7 @@ html[data-interface-skin="clean"] .button.primary {
color: #ffffff; color: #ffffff;
} }
/* ---------- Graphite ---------- */
html[data-interface-skin="graphite"][data-theme="light"] { html[data-interface-skin="graphite"][data-theme="light"] {
color-scheme: light; color-scheme: light;
--bg: #e4e6e8; --bg: #e4e6e8;
@@ -254,7 +253,7 @@ html[data-interface-skin="graphite"] .button.primary {
color: #ffffff; color: #ffffff;
} }
/* ---------- High contrast ---------- */
html[data-interface-skin="high-contrast"] { html[data-interface-skin="high-contrast"] {
--radius-control: 2px; --radius-control: 2px;
--radius-panel: 2px; --radius-panel: 2px;
@@ -358,7 +357,7 @@ html[data-interface-skin="high-contrast"] .button.primary {
} }
} }
/* ---------- Modern mail-inspired styles ---------- */
html[data-interface-skin="gmail"], html[data-interface-skin="gmail"],
html[data-interface-skin="proton"], html[data-interface-skin="proton"],
html[data-interface-skin="zoho"], html[data-interface-skin="zoho"],
@@ -671,7 +670,7 @@ html[data-interface-skin="minimal"] .button.primary {
color: var(--accent-ink); color: var(--accent-ink);
} }
/* ---------- Additional predefined mail styles ---------- */
html[data-interface-skin="fastmail"], html[data-interface-skin="fastmail"],
html[data-interface-skin="apple-mail"], html[data-interface-skin="apple-mail"],
html[data-interface-skin="yahoo"], html[data-interface-skin="yahoo"],
+7 -11
View File
@@ -1,4 +1,3 @@
/* Dedicated full-screen composer mode. */
.compose-dialog.compose-fullscreen { .compose-dialog.compose-fullscreen {
width: 100vw; width: 100vw;
height: 100dvh; height: 100dvh;
@@ -271,7 +270,7 @@ html[data-preview="bottom"] .reader-pane {
} }
} }
/* 0.0.3 mobile reliability pass */
.folder-quota { .folder-quota {
display: grid; display: grid;
gap: 5px; gap: 5px;
@@ -326,7 +325,7 @@ html[data-preview="bottom"] .reader-pane {
background: var(--danger); background: var(--danger);
} }
/* The visual Sieve builder must never let checkbox inputs inherit width:100%. */
.sieve-builder .checkbox-row input[type="checkbox"] { .sieve-builder .checkbox-row input[type="checkbox"] {
width: 15px; width: 15px;
min-width: 15px; min-width: 15px;
@@ -543,22 +542,19 @@ html[data-preview="bottom"] .reader-pane {
@media (max-width: 560px) { @media (max-width: 560px) {
.reader-toolbar .toolbar-menu>summary span { .reader-toolbar .toolbar-menu>summary span {
display: none; display: inline;
} }
.reader-toolbar .toolbar-menu>summary { .reader-toolbar .toolbar-menu>summary {
min-width: 30px; min-width: max-content;
min-height: 34px;
padding-inline: 7px; padding-inline: 7px;
} }
.reader-toolbar .toolbar-menu-popover { .reader-toolbar .toolbar-menu-popover {
position: fixed; position: fixed;
top: auto; min-width: 180px;
right: 8px; max-width: calc(100vw - 16px);
bottom: 8px;
left: 8px;
min-width: 0;
max-width: none;
} }
.compose-footer { .compose-footer {
-1
View File
@@ -1,4 +1,3 @@
/* OpenPGP */
.pgp-reader-bar { .pgp-reader-bar {
display: flex; display: flex;
align-items: center; align-items: center;
+35 -11
View File
@@ -1,5 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en" data-base-path="__RUSTBOX_BASE_PATH__" data-default-theme="__RUSTBOX_DEFAULT_THEME__" data-default-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-max-attachment-bytes="__RUSTBOX_MAX_ATTACHMENT_BYTES__" data-max-attachment-mb="__RUSTBOX_MAX_ATTACHMENT_MB__" data-max-message-bytes="__RUSTBOX_MAX_MESSAGE_BYTES__" data-max-message-mb="__RUSTBOX_MAX_MESSAGE_MB__" data-theme="dark" data-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-density="compact" data-preview="right"> <html lang="en" data-base-path="__RUSTBOX_BASE_PATH__" data-default-theme="__RUSTBOX_DEFAULT_THEME__"
data-default-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__"
data-max-attachment-bytes="__RUSTBOX_MAX_ATTACHMENT_BYTES__" data-max-attachment-mb="__RUSTBOX_MAX_ATTACHMENT_MB__"
data-max-message-bytes="__RUSTBOX_MAX_MESSAGE_BYTES__" data-max-message-mb="__RUSTBOX_MAX_MESSAGE_MB__"
data-theme="dark" data-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-density="compact"
data-preview="right">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -205,6 +210,10 @@
aria-label="Next message"><svg> aria-label="Next message"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-chevron-right"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-chevron-right"></use>
</svg></button> </svg></button>
<button id="reader-fullscreen" class="icon-button" type="button" title="Open full screen"
aria-label="Open message full screen" aria-pressed="false"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-external-link"></use>
</svg></button>
<button id="reply-btn" class="button quiet" type="button"><svg> <button id="reply-btn" class="button quiet" type="button"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use>
</svg><span data-i18n="reply">Reply</span></button> </svg><span data-i18n="reply">Reply</span></button>
@@ -270,7 +279,8 @@
aria-label="Add sender to contacts"><svg> aria-label="Add sender to contacts"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
</svg></button></div> </svg></button></div>
<div id="reader-to" class="reader-recipients" role="button" tabindex="0" aria-expanded="false" title="Show full addresses"></div> <div id="reader-to" class="reader-recipients" role="button" tabindex="0" aria-expanded="false"
title="Show full addresses"></div>
</div><time id="reader-date"></time> </div><time id="reader-date"></time>
</div> </div>
</header> </header>
@@ -449,9 +459,12 @@
<option value="text">Plain text</option> <option value="text">Plain text</option>
<option value="markdown">Markdown</option> <option value="markdown">Markdown</option>
</select></label> </select></label>
<span id="compose-status" class="muted"></span><span id="compose-message-size" class="muted compose-message-size" title="Estimated final message size">~0 / __RUSTBOX_MAX_MESSAGE_MB__ MiB</span> <span id="compose-status" class="muted"></span><span id="compose-message-size"
<div class="compose-submit-actions"><button type="button" class="button quiet close-dialog">Cancel</button><button class="muted compose-message-size" title="Estimated final message size">~0 / __RUSTBOX_MAX_MESSAGE_MB__
id="send-btn" type="submit" class="button primary"><svg> MiB</span>
<div class="compose-submit-actions"><button type="button"
class="button quiet close-dialog">Cancel</button><button id="send-btn" type="submit"
class="button primary"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use> <use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use>
</svg><span>Send</span></button></div> </svg><span>Send</span></button></div>
</footer> </footer>
@@ -595,7 +608,10 @@
<option value="nord">Nord</option> <option value="nord">Nord</option>
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors, </select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
spacing, panes and typography.</small></label> spacing, panes and typography.</small></label>
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input id="pref-preset-scale-unlocked" type="checkbox"><span>Allow UI scaling for predefined style</span><small class="muted">When enabled, the UI size control can override the scale defined by the selected preset.</small></label> <label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input
id="pref-preset-scale-unlocked" type="checkbox"><span>Allow UI scaling for predefined
style</span><small class="muted">When enabled, the UI size control can override the scale defined by
the selected preset.</small></label>
<label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset"> <label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset">
<option value="sage">Sage</option> <option value="sage">Sage</option>
<option value="blue">Blue</option> <option value="blue">Blue</option>
@@ -858,8 +874,13 @@
message text suggests a missing attachment</span></label> message text suggests a missing attachment</span></label>
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser <label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
spellcheck while composing</span></label> spellcheck while composing</span></label>
<label><span>Maximum attachment size</span><output id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</output><small class="muted">Per-file upload limit set with <code>max_attachment_size_mb</code> in rustbox.toml.</small></label> <label><span>Maximum attachment size</span><output
<label><span>Maximum message size</span><output id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__ MiB</output><small class="muted">Hard limit for the complete RFC822/MIME message sent to the MTA, including base64/MIME overhead.</small></label> id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</output><small
class="muted">Per-file upload limit set with <code>max_attachment_size_mb</code> in
rustbox.toml.</small></label>
<label><span>Maximum message size</span><output id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__
MiB</output><small class="muted">Hard limit for the complete RFC822/MIME message sent to the MTA,
including base64/MIME overhead.</small></label>
</div> </div>
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a <p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
plain-text fallback.</p> plain-text fallback.</p>
@@ -1073,10 +1094,12 @@
</div> </div>
<div class="diagnostics-grid"> <div class="diagnostics-grid">
<section class="diagnostic-card"> <section class="diagnostic-card">
<div class="section-heading"><span>IMAP</span><strong id="diagnostic-imap-status">Not checked</strong></div> <div class="section-heading"><span>IMAP</span><strong id="diagnostic-imap-status">Not checked</strong>
</div>
<div id="diagnostic-imap-summary" class="capability-grid"></div> <div id="diagnostic-imap-summary" class="capability-grid"></div>
<div id="diagnostic-imap-caps" class="capability-list"></div> <div id="diagnostic-imap-caps" class="capability-list"></div>
<div class="inline-actions diagnostic-imap-actions"><button id="diagnostic-imap-reconnect" class="button quiet" type="button">Reconnect</button></div> <div class="inline-actions diagnostic-imap-actions"><button id="diagnostic-imap-reconnect"
class="button quiet" type="button">Reconnect</button></div>
</section> </section>
<section class="diagnostic-card"> <section class="diagnostic-card">
<div class="section-heading"><span>SMTP</span><strong id="diagnostic-smtp-status">Not checked</strong> <div class="section-heading"><span>SMTP</span><strong id="diagnostic-smtp-status">Not checked</strong>
@@ -1139,7 +1162,8 @@
<dt>Author</dt> <dt>Author</dt>
<dd>@linuxiarz.pl</dd> <dd>@linuxiarz.pl</dd>
<dt>Repository</dt> <dt>Repository</dt>
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank" rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd> <dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank"
rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
</dl> </dl>
<div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard <div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard
shortcuts</button></div> shortcuts</button></div>
+15 -3
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 00-bootstrap.js */
(() => { (() => {
"use strict"; "use strict";
@@ -50,13 +50,23 @@
markReadTimer: null, markReadTimer: null,
ws: null, ws: null,
wsRetryTimer: null, wsRetryTimer: null,
wsRetryAttempt: 0,
wsWatchdogTimer: null,
wsLastHeartbeatAt: 0,
wsShouldConnect: false,
wsConnectedOnce: false,
realtimeRefreshTimer: null, realtimeRefreshTimer: null,
realtimeRefreshEvents: [],
realtimePollTimer: null,
realtimeSyncPromise: null,
realtimeSeenEvents: new Map(),
calendarReminderTimer: null, calendarReminderTimer: null,
composeDraftId: null, composeDraftId: null,
composeAttachments: [], composeAttachments: [],
composeDirty: false, composeDirty: false,
composeNeedsRemoteSync: false, composeNeedsRemoteSync: false,
composeUploading: false, composeUploading: false,
composeUploadController: null,
composeReplyMeta: null, composeReplyMeta: null,
composeInsertRange: null, composeInsertRange: null,
composeSelectedImage: null, composeSelectedImage: null,
@@ -79,6 +89,8 @@
quotaWarningLevel: null, quotaWarningLevel: null,
networkOnline: navigator.onLine, networkOnline: navigator.onLine,
listStateTimer: null, listStateTimer: null,
readerFullscreenHistory: false,
readerScrollTopBeforeFullscreen: 0,
}; };
const $ = (selector, parent = document) => parent.querySelector(selector); const $ = (selector, parent = document) => parent.querySelector(selector);
@@ -105,8 +117,8 @@
function stripRustBoxUiLeak(value) { function stripRustBoxUiLeak(value) {
let text = String(value ?? ""); let text = String(value ?? "");
// Strip only RustBox's own embedded SVG sprite fragments if malformed MIME
// content captured adjacent UI markup. Legitimate SVG/code in mail remains.
text = text.replace(/<svg\b[^>]*>\s*<use\b[^>]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, ""); text = text.replace(/<svg\b[^>]*>\s*<use\b[^>]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, "");
text = text.replace(/&lt;svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?&lt;\/svg&gt;/gi, ""); text = text.replace(/&lt;svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?&lt;\/svg&gt;/gi, "");
return text; return text;
+19 -72
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 10-core.js */
function setConnectivityState(online, detail = "") { function setConnectivityState(online, detail = "") {
state.networkOnline = Boolean(online); state.networkOnline = Boolean(online);
document.body.classList.toggle("network-offline", !state.networkOnline); document.body.classList.toggle("network-offline", !state.networkOnline);
@@ -124,9 +124,17 @@ async function request(path, options = {}) {
throw error; throw error;
} }
function uploadRequest(path, formData, onProgress = null) { function uploadRequest(path, formData, onProgress = null, signal = null) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", abortUpload);
callback(value);
};
const abortUpload = () => xhr.abort();
xhr.open("POST", apiPath(path), true); xhr.open("POST", apiPath(path), true);
xhr.withCredentials = true; xhr.withCredentials = true;
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId); if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
@@ -136,8 +144,8 @@ function uploadRequest(path, formData, onProgress = null) {
if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100)); if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
}); });
} }
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error"))); xhr.addEventListener("error", () => finish(reject, new Error("Upload failed: network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); xhr.addEventListener("abort", () => finish(reject, new DOMException("Upload cancelled", "AbortError")));
xhr.addEventListener("load", () => { xhr.addEventListener("load", () => {
const contentType = xhr.getResponseHeader("content-type") || ""; const contentType = xhr.getResponseHeader("content-type") || "";
let payload = xhr.responseText || ""; let payload = xhr.responseText || "";
@@ -148,11 +156,16 @@ function uploadRequest(path, formData, onProgress = null) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`; const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
const error = new Error(message); const error = new Error(message);
error.status = xhr.status; error.status = xhr.status;
reject(error); finish(reject, error);
return; return;
} }
resolve(payload); finish(resolve, payload);
}); });
if (signal?.aborted) {
finish(reject, new DOMException("Upload cancelled", "AbortError"));
return;
}
signal?.addEventListener("abort", abortUpload, { once: true });
xhr.send(formData); xhr.send(formData);
}); });
} }
@@ -251,72 +264,6 @@ function toast(message, kind = "info", duration = null, action = null) {
resume(); resume();
} }
function progressToast(message) {
const node = el("div", "toast toast-info toast-progress");
node.setAttribute("role", "status");
const icon = el("span", "toast-icon");
icon.innerHTML = svgIcon("paperclip");
const content = el("span", "toast-progress-content");
const text = el("span", "toast-message", message);
const track = el("span", "toast-progress-track");
const bar = el("span", "toast-progress-bar");
track.append(bar);
content.append(text, track);
const value = el("span", "toast-progress-value", "0%");
const close = el("button", "toast-close");
close.type = "button";
close.setAttribute("aria-label", "Dismiss notification");
close.innerHTML = svgIcon("x");
let stack = $("#toast-stack");
const openDialogs = $$("dialog[open]");
if (openDialogs.length) {
const dialog = openDialogs[openDialogs.length - 1];
stack = dialog.querySelector(":scope > .modal-toast-stack");
if (!stack) {
stack = el("div", "toast-stack modal-toast-stack");
stack.setAttribute("aria-live", "polite");
dialog.append(stack);
}
}
node.append(icon, content, value, close);
stack.append(node);
requestAnimationFrame(() => node.classList.add("visible"));
let dismissed = false;
const dismiss = (delay = 0) => {
if (dismissed) return;
dismissed = true;
setTimeout(() => {
node.classList.remove("visible");
setTimeout(() => node.remove(), 160);
}, Math.max(0, delay));
};
close.addEventListener("click", () => dismiss());
return {
update(percent, label = null) {
const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
bar.style.width = `${normalized}%`;
value.textContent = `${normalized}%`;
if (label) text.textContent = label;
},
complete(label = trText("Upload complete")) {
bar.style.width = "100%";
value.textContent = "100%";
text.textContent = label;
node.classList.remove("toast-info");
node.classList.add("toast-success");
icon.innerHTML = svgIcon("check-circle");
dismiss(1200);
},
fail(label) {
text.textContent = label || trText("Upload failed");
node.classList.remove("toast-info");
node.classList.add("toast-error");
icon.innerHTML = svgIcon("circle-alert");
dismiss(4800);
},
dismiss,
};
}
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) { function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
+1 -1
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 20-preferences-auth.js */
function resolveTheme(theme) { function resolveTheme(theme) {
if (theme !== "system") return theme; if (theme !== "system") return theme;
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
+437 -437
View File
@@ -1,453 +1,453 @@
/* RustBox UI module: 30-folders-tags.js */
function folderIconName(folder) {
const attrs = (folder.attributes || []).join(" ").toLowerCase();
const name = String(folder.name || "").toLowerCase();
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
return "folder";
}
function orderedFolders() { function folderIconName(folder) {
return [...state.folders].sort((a, b) => { const attrs = (folder.attributes || []).join(" ").toLowerCase();
if (a.name.toUpperCase() === "INBOX") return -1; const name = String(folder.name || "").toLowerCase();
if (b.name.toUpperCase() === "INBOX") return 1; if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
return a.name.localeCompare(b.name); if (attrs.includes("\\sent") || name.includes("sent")) return "send";
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
return "folder";
}
function orderedFolders() {
return [...state.folders].sort((a, b) => {
if (a.name.toUpperCase() === "INBOX") return -1;
if (b.name.toUpperCase() === "INBOX") return 1;
return a.name.localeCompare(b.name);
});
}
function folderDepth(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return 0;
return Math.min(8, folder.name.split(delimiter).length - 1);
}
function folderLeafName(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
}
function folderHasChildren(folder, folders = state.folders) {
const delimiter = folder.delimiter || "/";
if (!delimiter) return false;
const prefix = `${folder.name}${delimiter}`;
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
}
function folderAncestors(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return [];
const parts = folder.name.split(delimiter);
const result = [];
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
return result;
}
function collapsedFolderStorageKey() {
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
}
function loadCollapsedFolders() {
try {
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
} catch (_) {
state.collapsedFolders = new Set();
}
}
function saveCollapsedFolders() {
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
}
function folderIsHiddenByCollapsedAncestor(folder) {
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
}
function toggleFolderBranch(folder) {
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
else state.collapsedFolders.add(folder.name);
saveCollapsedFolders();
renderFolders();
}
function setFolderDrawerOpen(open) {
const shouldOpen = Boolean(open) && innerWidth <= 820;
$("#folder-pane")?.classList.toggle("open", shouldOpen);
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
}
function toggleFolderDrawer() {
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
}
function renderFolders() {
const list = $("#folder-list");
list.replaceChildren();
const folders = orderedFolders();
for (const folder of folders) {
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
const row = el("div", "folder-row");
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
const hasChildren = folderHasChildren(folder, folders);
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
expander.type = "button";
expander.tabIndex = hasChildren ? 0 : -1;
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
if (hasChildren) {
expander.innerHTML = svgIcon("chevron-right");
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
}
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
button.type = "button";
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
button.addEventListener("dragover", (event) => {
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
button.classList.add("drop-target");
}); });
} button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
button.addEventListener("drop", async (event) => {
function folderDepth(folder) { event.preventDefault();
const delimiter = folder.delimiter || "/"; button.classList.remove("drop-target");
if (!delimiter || !folder.name.includes(delimiter)) return 0; try {
return Math.min(8, folder.name.split(delimiter).length - 1); const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
} if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
function folderLeafName(folder) { } catch (error) { toast(error.message || "Could not move message", "error"); }
const delimiter = folder.delimiter || "/"; });
if (!delimiter || !folder.name.includes(delimiter)) return folder.name; button.addEventListener("click", async () => {
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name; saveMessageListState();
} state.folder = folder.name;
state.selected = null;
function folderHasChildren(folder, folders = state.folders) {
const delimiter = folder.delimiter || "/";
if (!delimiter) return false;
const prefix = `${folder.name}${delimiter}`;
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
}
function folderAncestors(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return [];
const parts = folder.name.split(delimiter);
const result = [];
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
return result;
}
function collapsedFolderStorageKey() {
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
}
function loadCollapsedFolders() {
try {
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
} catch (_) {
state.collapsedFolders = new Set();
}
}
function saveCollapsedFolders() {
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
}
function folderIsHiddenByCollapsedAncestor(folder) {
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
}
function toggleFolderBranch(folder) {
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
else state.collapsedFolders.add(folder.name);
saveCollapsedFolders();
renderFolders();
}
function setFolderDrawerOpen(open) {
const shouldOpen = Boolean(open) && innerWidth <= 820;
$("#folder-pane")?.classList.toggle("open", shouldOpen);
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
}
function toggleFolderDrawer() {
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
}
function renderFolders() {
const list = $("#folder-list");
list.replaceChildren();
const folders = orderedFolders();
for (const folder of folders) {
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
const row = el("div", "folder-row");
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
const hasChildren = folderHasChildren(folder, folders);
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
expander.type = "button";
expander.tabIndex = hasChildren ? 0 : -1;
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
if (hasChildren) {
expander.innerHTML = svgIcon("chevron-right");
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
}
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
button.type = "button";
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
button.addEventListener("dragover", (event) => {
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
button.classList.add("drop-target");
});
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
button.addEventListener("drop", async (event) => {
event.preventDefault();
button.classList.remove("drop-target");
try {
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
} catch (error) { toast(error.message || "Could not move message", "error"); }
});
button.addEventListener("click", async () => {
saveMessageListState();
state.folder = folder.name;
state.selected = null;
state.selectedMessages.clear();
clearActiveSearchContext();
state.currentDetail = null;
renderFolders();
closeReader();
setFolderDrawerOpen(false);
await loadMessages(true, { restoreState: true });
});
row.append(expander, button);
list.append(row);
}
renderMoveOptions();
}
function updateUnreadAppBadge() {
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
try {
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
} catch (_) { }
}
async function loadFolders(restoreView = false) {
$("#connection-label").textContent = "Connecting";
try {
const data = await request("/folders", { method: "GET" });
state.folders = data.folders || [];
updateUnreadAppBadge();
loadCollapsedFolders();
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
if (restoreView) {
const remembered = localStorage.getItem(lastFolderStorageKey());
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
}
if (!state.folders.some((folder) => folder.name === state.folder)) {
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
}
renderFolders();
$("#connection-label").textContent = "Connected";
if (restoreView) {
const restoredSearch = readActiveSearchContext();
if (restoredSearch) {
state.searchContext = restoredSearch;
const restoredParams = new URLSearchParams(restoredSearch.params);
$("#search-input").value = restoredParams.get("q") || "";
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
return;
}
}
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true, { restoreState: restoreView });
} catch (error) {
$("#connection-label").textContent = "Connection error";
if (error.status === 401) return showLogin();
toast(error.message, "error");
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
}
function renderMoveOptions() {
const selects = [
["#move-select", "Move to…"],
["#copy-select", "Copy to…"],
["#bulk-move-select", "Move to…"],
["#bulk-copy-select", "Copy to…"],
];
for (const [selector, placeholder] of selects) {
const select = $(selector);
if (!select) continue;
select.replaceChildren(new Option(placeholder, ""));
for (const folder of orderedFolders()) {
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
select.add(new Option(folder.name, folder.name));
}
}
}
function messageKey(message) {
return `${message.folder || state.folder}\u0000${message.uid}`;
}
function activeSearchHighlightTerms() {
if (!state.searchContext?.params) return [];
const params = new URLSearchParams(state.searchContext.params);
const terms = [];
for (const key of ["from", "to", "subject"]) {
const value = (params.get(key) || "").trim();
if (value) terms.push(value);
}
const query = (params.get("q") || "").trim();
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
for (const token of tokens) {
const cleaned = token.replace(/^"|"$/g, "");
const colon = cleaned.indexOf(":");
if (colon > 0) {
const op = cleaned.slice(0, colon).toLowerCase();
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
} else if (cleaned.length >= 2) {
terms.push(cleaned);
}
}
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
.sort((a, b) => b.length - a.length)
.slice(0, 12);
}
function renderHighlightedText(node, value, terms) {
const text = String(value || "");
node.replaceChildren();
if (!terms.length || !text) {
node.textContent = text;
return;
}
const lower = text.toLocaleLowerCase();
let offset = 0;
while (offset < text.length) {
let matchIndex = -1;
let matchTerm = "";
for (const term of terms) {
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
matchIndex = index;
matchTerm = term;
}
}
if (matchIndex < 0) {
node.append(document.createTextNode(text.slice(offset)));
break;
}
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
node.append(mark);
offset = matchIndex + matchTerm.length;
}
}
function selectedMessages() {
const source = state.searchAllResults || state.messages;
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
}
function renderBulkToolbar() {
const selected = selectedMessages();
const toolbar = $("#bulk-toolbar");
toolbar.classList.toggle("hidden", !state.selectionMode);
$("#bulk-count").textContent = `${selected.length} selected`;
const selectionUniverse = state.searchAllResults || state.messages;
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
$("#bulk-select-all").checked = allLoaded;
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
const selectSearch = $("#bulk-select-search");
if (selectSearch) {
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
selectSearch.disabled = !state.searchContext;
}
const junkButton = $("#bulk-junk");
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
const toggle = $("#select-mode-btn");
if (toggle) {
toggle.classList.toggle("active", state.selectionMode);
toggle.setAttribute("aria-pressed", String(state.selectionMode));
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
toggle.setAttribute("aria-label", toggle.title);
}
}
function setSelectionMode(enabled) {
state.selectionMode = Boolean(enabled);
if (!state.selectionMode) {
state.selectedMessages.clear();
state.searchAllResults = null;
}
renderMessages();
}
function setMessageSelected(message, selected) {
if (!state.selectionMode) state.selectionMode = true;
const key = messageKey(message);
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
renderMessages();
}
function clearBulkSelection() {
state.selectedMessages.clear();
state.searchAllResults = null;
renderMessages();
}
async function selectAllSearchResults() {
if (!state.searchContext) return;
const button = $("#bulk-select-search");
setBusy(button, true, "Selecting…");
try {
const params = new URLSearchParams(state.searchContext.params);
params.set("all", "true");
params.delete("offset");
const data = await request(`/search?${params.toString()}`, { method: "GET" });
state.searchAllResults = data.results || [];
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
state.total = state.searchAllResults.length;
renderMessages();
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
} catch (error) {
toast(error.message, "error");
} finally {
setBusy(button, false);
}
}
function renderTagOptions() {
const select = $("#bulk-tag-select");
if (!select) return;
select.replaceChildren(new Option("Add label…", ""));
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
}
function renderTags() {
const list = $("#tag-list");
if (!list) return;
list.replaceChildren();
for (const tag of state.tags) {
const button = el("button", "tag-item");
button.type = "button";
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
button.addEventListener("click", () => loadTagMessages(tag));
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
list.append(button);
}
renderTagOptions();
}
async function loadTags() {
try {
const data = await request("/tags", { method: "GET" });
state.tags = data.tags || [];
renderTags();
} catch (error) { toast(error.message, "error"); }
}
async function loadTagMessages(tag) {
try {
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
state.messages = data.messages || [];
state.total = data.total_count || state.messages.length;
state.selectedMessages.clear(); state.selectedMessages.clear();
clearActiveSearchContext(); clearActiveSearchContext();
renderMessages(); state.currentDetail = null;
$("#folder-title").textContent = `Label: ${tag.name}`; renderFolders();
$("#message-count").textContent = `${state.total} results`; closeReader();
} catch (error) { toast(error.message, "error"); } setFolderDrawerOpen(false);
await loadMessages(true, { restoreState: true });
});
row.append(expander, button);
list.append(row);
} }
renderMoveOptions();
}
function openTagDialog(tag = null) { function updateUnreadAppBadge() {
$("#tag-id").value = tag?.id || ""; const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
$("#tag-name").value = tag?.name || ""; document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280"; try {
$("#tag-delete").disabled = !tag; if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
$("#tag-dialog").showModal(); else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
setTimeout(() => $("#tag-name").focus(), 20); } catch (_) { }
}
async function loadFolders(restoreView = false) {
$("#connection-label").textContent = "Connecting";
try {
const data = await request("/folders", { method: "GET" });
state.folders = data.folders || [];
updateUnreadAppBadge();
loadCollapsedFolders();
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
if (restoreView) {
const remembered = localStorage.getItem(lastFolderStorageKey());
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
}
if (!state.folders.some((folder) => folder.name === state.folder)) {
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
}
renderFolders();
$("#connection-label").textContent = "Connected";
if (restoreView) {
const restoredSearch = readActiveSearchContext();
if (restoredSearch) {
state.searchContext = restoredSearch;
const restoredParams = new URLSearchParams(restoredSearch.params);
$("#search-input").value = restoredParams.get("q") || "";
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
return;
}
}
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true, { restoreState: restoreView });
} catch (error) {
$("#connection-label").textContent = "Connection error";
if (error.status === 401) return showLogin();
toast(error.message, "error");
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
} }
}
async function saveTag(event) { function renderMoveOptions() {
event.preventDefault(); const selects = [
const id = $("#tag-id").value; ["#move-select", "Move to…"],
const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value }; ["#copy-select", "Copy to…"],
if (!payload.name) return; ["#bulk-move-select", "Move to…"],
try { ["#bulk-copy-select", "Copy to…"],
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); ];
$("#tag-dialog").close(); for (const [selector, placeholder] of selects) {
await loadTags(); const select = $(selector);
toast("Label saved"); if (!select) continue;
} catch (error) { toast(error.message, "error"); } select.replaceChildren(new Option(placeholder, ""));
for (const folder of orderedFolders()) {
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
select.add(new Option(folder.name, folder.name));
}
} }
}
async function deleteTag() { function messageKey(message) {
const id = $("#tag-id").value; return `${message.folder || state.folder}\u0000${message.uid}`;
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return; }
try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
catch (error) { toast(error.message, "error"); } function activeSearchHighlightTerms() {
if (!state.searchContext?.params) return [];
const params = new URLSearchParams(state.searchContext.params);
const terms = [];
for (const key of ["from", "to", "subject"]) {
const value = (params.get(key) || "").trim();
if (value) terms.push(value);
} }
const query = (params.get("q") || "").trim();
async function promptTag(label, tags = state.tags) { const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
if (!tags.length) { toast("Create a label first", "error"); return null; } for (const token of tokens) {
const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" }); const cleaned = token.replace(/^"|"$/g, "");
return tags.find((tag) => String(tag.id) === String(answer)) || null; const colon = cleaned.indexOf(":");
if (colon > 0) {
const op = cleaned.slice(0, colon).toLowerCase();
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
} else if (cleaned.length >= 2) {
terms.push(cleaned);
}
} }
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
.sort((a, b) => b.length - a.length)
.slice(0, 12);
}
async function addTagToMessage(message, tag) { function renderHighlightedText(node, value, terms) {
const folder = message.folder || state.folder; const text = String(value || "");
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) }); node.replaceChildren();
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }]; if (!terms.length || !text) {
node.textContent = text;
return;
}
const lower = text.toLocaleLowerCase();
let offset = 0;
while (offset < text.length) {
let matchIndex = -1;
let matchTerm = "";
for (const term of terms) {
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
matchIndex = index;
matchTerm = term;
}
}
if (matchIndex < 0) {
node.append(document.createTextNode(text.slice(offset)));
break;
}
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
node.append(mark);
offset = matchIndex + matchTerm.length;
}
}
function selectedMessages() {
const source = state.searchAllResults || state.messages;
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
}
function renderBulkToolbar() {
const selected = selectedMessages();
const toolbar = $("#bulk-toolbar");
toolbar.classList.toggle("hidden", !state.selectionMode);
$("#bulk-count").textContent = `${selected.length} selected`;
const selectionUniverse = state.searchAllResults || state.messages;
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
$("#bulk-select-all").checked = allLoaded;
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
const selectSearch = $("#bulk-select-search");
if (selectSearch) {
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
selectSearch.disabled = !state.searchContext;
}
const junkButton = $("#bulk-junk");
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
const toggle = $("#select-mode-btn");
if (toggle) {
toggle.classList.toggle("active", state.selectionMode);
toggle.setAttribute("aria-pressed", String(state.selectionMode));
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
toggle.setAttribute("aria-label", toggle.title);
}
}
function setSelectionMode(enabled) {
state.selectionMode = Boolean(enabled);
if (!state.selectionMode) {
state.selectedMessages.clear();
state.searchAllResults = null;
}
renderMessages();
}
function setMessageSelected(message, selected) {
if (!state.selectionMode) state.selectionMode = true;
const key = messageKey(message);
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
renderMessages();
}
function clearBulkSelection() {
state.selectedMessages.clear();
state.searchAllResults = null;
renderMessages();
}
async function selectAllSearchResults() {
if (!state.searchContext) return;
const button = $("#bulk-select-search");
setBusy(button, true, "Selecting…");
try {
const params = new URLSearchParams(state.searchContext.params);
params.set("all", "true");
params.delete("offset");
const data = await request(`/search?${params.toString()}`, { method: "GET" });
state.searchAllResults = data.results || [];
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
state.total = state.searchAllResults.length;
renderMessages();
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
} catch (error) {
toast(error.message, "error");
} finally {
setBusy(button, false);
}
}
function renderTagOptions() {
const select = $("#bulk-tag-select");
if (!select) return;
select.replaceChildren(new Option("Add label…", ""));
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
}
function renderTags() {
const list = $("#tag-list");
if (!list) return;
list.replaceChildren();
for (const tag of state.tags) {
const button = el("button", "tag-item");
button.type = "button";
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
button.addEventListener("click", () => loadTagMessages(tag));
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
list.append(button);
}
renderTagOptions();
}
async function loadTags() {
try {
const data = await request("/tags", { method: "GET" });
state.tags = data.tags || [];
renderTags();
} catch (error) { toast(error.message, "error"); }
}
async function loadTagMessages(tag) {
try {
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
state.messages = data.messages || [];
state.total = data.total_count || state.messages.length;
state.selectedMessages.clear();
clearActiveSearchContext();
renderMessages();
$("#folder-title").textContent = `Label: ${tag.name}`;
$("#message-count").textContent = `${state.total} results`;
} catch (error) { toast(error.message, "error"); }
}
function openTagDialog(tag = null) {
$("#tag-id").value = tag?.id || "";
$("#tag-name").value = tag?.name || "";
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280";
$("#tag-delete").disabled = !tag;
$("#tag-dialog").showModal();
setTimeout(() => $("#tag-name").focus(), 20);
}
async function saveTag(event) {
event.preventDefault();
const id = $("#tag-id").value;
const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value };
if (!payload.name) return;
try {
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
$("#tag-dialog").close();
await loadTags();
toast("Label saved");
} catch (error) { toast(error.message, "error"); }
}
async function deleteTag() {
const id = $("#tag-id").value;
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return;
try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function promptTag(label, tags = state.tags) {
if (!tags.length) { toast("Create a label first", "error"); return null; }
const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" });
return tags.find((tag) => String(tag.id) === String(answer)) || null;
}
async function addTagToMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) });
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
renderMessages();
await loadTags();
}
async function removeTagFromMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
renderMessages();
await loadTags();
}
async function bulkAddTag(tagId) {
const items = selectedMessages();
const tag = state.tags.find((item) => item.id === tagId);
if (!items.length || !tag) return;
try {
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) });
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
renderMessages(); renderMessages();
await loadTags(); await loadTags();
} toast(`Label “${tag.name}” added`);
} catch (error) { toast(error.message, "error"); }
async function removeTagFromMessage(message, tag) { }
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
renderMessages();
await loadTags();
}
async function bulkAddTag(tagId) {
const items = selectedMessages();
const tag = state.tags.find((item) => item.id === tagId);
if (!items.length || !tag) return;
try {
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) });
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
renderMessages();
await loadTags();
toast(`Label “${tag.name}” added`);
} catch (error) { toast(error.message, "error"); }
}
+91 -2
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 40-messages-reader.js */
async function loadMessages(reset = false, options = {}) { async function loadMessages(reset = false, options = {}) {
const list = $("#message-list"); const list = $("#message-list");
const restoreState = Boolean(options.restoreState); const restoreState = Boolean(options.restoreState);
@@ -117,7 +117,17 @@ function renderMessages() {
flagButton.setAttribute("aria-label", flagButton.title); flagButton.setAttribute("aria-label", flagButton.title);
flagButton.innerHTML = svgIcon("star"); flagButton.innerHTML = svgIcon("star");
flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); }); flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); });
quick.append(readButton, flagButton); const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
fullscreenButton.type = "button";
fullscreenButton.title = "Open full screen";
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
fullscreenButton.innerHTML = svgIcon("external-link");
fullscreenButton.addEventListener("click", async (event) => {
event.stopPropagation();
await openMessage(folder, message.uid);
enterReaderFullscreen();
});
quick.append(readButton, flagButton, fullscreenButton);
icons.append(quick); icons.append(quick);
if (Array.isArray(message.tags) && message.tags.length) { if (Array.isArray(message.tags) && message.tags.length) {
const wrap = el("span", "message-tags"); const wrap = el("span", "message-tags");
@@ -177,6 +187,84 @@ async function senderIsSavedContact(email) {
} }
} }
function readerFullscreenUrl(detail = state.currentDetail) {
const url = new URL(location.href);
if (detail?.folder && Number.isFinite(Number(detail.uid))) {
url.searchParams.set("mail_folder", detail.folder);
url.searchParams.set("mail_uid", String(detail.uid));
url.searchParams.set("mail_fullscreen", "1");
}
return url;
}
function restoreReaderScroll() {
const reader = $("#reader-content");
if (!reader) return;
const top = Math.max(0, Number(state.readerScrollTopBeforeFullscreen) || 0);
requestAnimationFrame(() => { reader.scrollTop = top; });
}
function setReaderFullscreen(enabled, options = {}) {
const reader = $("#reader");
const button = $("#reader-fullscreen");
if (!reader || !button) return;
const next = Boolean(enabled);
if (next === reader.classList.contains("reader-fullscreen")) return;
if (next) {
state.readerScrollTopBeforeFullscreen = $("#reader-content")?.scrollTop || 0;
reader.classList.add("reader-fullscreen");
document.body.classList.add("reader-fullscreen-open");
button.setAttribute("aria-pressed", "true");
button.title = "Exit full screen";
button.setAttribute("aria-label", "Exit message full screen");
if (options.pushHistory !== false && state.currentDetail) {
history.pushState({ rustboxMailFullscreen: true, folder: state.currentDetail.folder, uid: state.currentDetail.uid }, "", readerFullscreenUrl());
state.readerFullscreenHistory = true;
}
} else {
reader.classList.remove("reader-fullscreen");
document.body.classList.remove("reader-fullscreen-open");
button.setAttribute("aria-pressed", "false");
button.title = "Open full screen";
button.setAttribute("aria-label", "Open message full screen");
state.readerFullscreenHistory = false;
if (options.keepUrl !== true && new URL(location.href).searchParams.get("mail_fullscreen") === "1") {
const url = new URL(location.href);
url.searchParams.delete("mail_fullscreen");
history.replaceState(history.state && history.state.rustboxMailFullscreen ? null : history.state, "", url);
}
restoreReaderScroll();
}
}
function enterReaderFullscreen() {
if (!state.currentDetail) return;
setReaderFullscreen(true);
}
function exitReaderFullscreen() {
if (!$("#reader")?.classList.contains("reader-fullscreen")) return;
if (state.readerFullscreenHistory) history.back();
else setReaderFullscreen(false, { pushHistory: false });
}
function toggleReaderFullscreen() {
if ($("#reader")?.classList.contains("reader-fullscreen")) exitReaderFullscreen();
else enterReaderFullscreen();
}
async function openMessageTarget(accountId, folder, uid, fullscreen = false) {
if (accountId && accountId !== state.accountId) await switchAccount(accountId);
if (folder && folder !== state.folder) {
state.folder = folder;
renderFolders();
await loadMessages(true);
}
if (folder && Number.isFinite(Number(uid))) await openMessage(folder, Number(uid));
if (fullscreen && state.currentDetail) setReaderFullscreen(true, { pushHistory: false });
}
async function openMessage(folder, uid) { async function openMessage(folder, uid) {
clearTimeout(state.markReadTimer); clearTimeout(state.markReadTimer);
state.markReadTimer = null; state.markReadTimer = null;
@@ -756,6 +844,7 @@ async function importVcardAttachment(folder, uid, attachment) {
function closeReader() { function closeReader() {
clearTimeout(state.markReadTimer); clearTimeout(state.markReadTimer);
state.markReadTimer = null; state.markReadTimer = null;
if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false });
$("#reader").classList.remove("mobile-open"); $("#reader").classList.remove("mobile-open");
$("#reader-empty").classList.remove("hidden"); $("#reader-empty").classList.remove("hidden");
$("#reader-content").classList.add("hidden"); $("#reader-content").classList.add("hidden");
+36 -25
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 50-compose.js */
function parseRecipients(raw) { function parseRecipients(raw) {
return raw.split(",").map((value) => value.trim()).filter(Boolean); return raw.split(",").map((value) => value.trim()).filter(Boolean);
} }
@@ -98,7 +98,7 @@ function estimatedMimeAttachmentBytes(rawBytes) {
function estimateComposeMessageBytes(extraFiles = []) { function estimateComposeMessageBytes(extraFiles = []) {
const editor = $("#compose-editor"); const editor = $("#compose-editor");
let bytes = 4096; // envelope headers, MIME boundaries and normal metadata let bytes = 4096;
for (const selector of ["#compose-to", "#compose-cc", "#compose-bcc", "#compose-subject"]) bytes += utf8Bytes($(selector)?.value); for (const selector of ["#compose-to", "#compose-cc", "#compose-bcc", "#compose-subject"]) bytes += utf8Bytes($(selector)?.value);
if (editor) { if (editor) {
const plainBytes = utf8Bytes(editor.innerText); const plainBytes = utf8Bytes(editor.innerText);
@@ -404,27 +404,37 @@ function setComposeUploadProgress(visible, percent = 0, label = trText("Uploadin
} }
async function uploadComposeFiles(files, options = {}) { async function uploadComposeFiles(files, options = {}) {
const list = [...(files || [])]; const incoming = [...(files || [])];
const announce = options.announce !== false; if (!incoming.length) return [];
if (state.composeUploading) return [];
const existing = new Set(state.composeAttachments.map((attachment) => `${String(attachment.filename || "")}\u0000${Number(attachment.size || 0)}`));
const selected = new Set();
const list = incoming.filter((file) => {
const key = `${String(file.name || "")}\u0000${Number(file.size || 0)}`;
if (existing.has(key) || selected.has(key)) return false;
selected.add(key);
return true;
});
if (!list.length) return []; if (!list.length) return [];
if (state.composeUploading) { if (announce) toast(trText("Another upload is already in progress"), "warning"); return []; }
const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024)); const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024));
const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024))); const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024)));
const oversized = list.find((file) => Number(file.size || 0) > maxBytes); const oversized = list.find((file) => Number(file.size || 0) > maxBytes);
if (oversized) { if (oversized) {
toast(`${oversized.name}: ${trText("maximum attachment size is")} ${maxMiB} MiB`, "error"); setComposeUploadProgress(true, 0, `${oversized.name}: ${trText("maximum attachment size is")} ${maxMiB} MiB`);
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4200);
return []; return [];
} }
const maxMessageBytes = Math.max(1, Number(root.dataset.maxMessageBytes || 50 * 1024 * 1024)); const maxMessageBytes = Math.max(1, Number(root.dataset.maxMessageBytes || 50 * 1024 * 1024));
const maxMessageMiB = Math.max(1, Number(root.dataset.maxMessageMb || Math.round(maxMessageBytes / 1024 / 1024))); const maxMessageMiB = Math.max(1, Number(root.dataset.maxMessageMb || Math.round(maxMessageBytes / 1024 / 1024)));
const estimatedAfterUpload = updateComposeMessageSize(list); const estimatedAfterUpload = updateComposeMessageSize(list);
if (estimatedAfterUpload > maxMessageBytes) { if (estimatedAfterUpload > maxMessageBytes) {
toast(`${trText("Estimated message size exceeds the configured limit")} (${formatComposeBytes(estimatedAfterUpload)} / ${maxMessageMiB} MiB). ${trText("The exact size will be checked before sending.")}`, "warning"); setComposeUploadProgress(true, 0, `${trText("Estimated message size exceeds the configured limit")} (${formatComposeBytes(estimatedAfterUpload)} / ${maxMessageMiB} MiB)`);
} }
state.composeUploading = true; state.composeUploading = true;
const controller = new AbortController();
state.composeUploadController = controller;
const uploadLabel = list.length === 1 ? trText("Uploading attachment…") : trText("Uploading files…"); const uploadLabel = list.length === 1 ? trText("Uploading attachment…") : trText("Uploading files…");
setComposeUploadProgress(true, 0, uploadLabel); setComposeUploadProgress(true, 0, uploadLabel);
const progress = progressToast(uploadLabel);
await saveCurrentDraft(true, true); await saveCurrentDraft(true, true);
const id = ensureDraftId(); const id = ensureDraftId();
const added = []; const added = [];
@@ -435,27 +445,28 @@ async function uploadComposeFiles(files, options = {}) {
form.append("file", file, file.name); form.append("file", file, file.name);
const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => { const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => {
const overall = Math.round(((index + (percent / 100)) / list.length) * 100); const overall = Math.round(((index + (percent / 100)) / list.length) * 100);
const label = `${trText("Uploading attachment…")} ${file.name}`; setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`);
setComposeUploadProgress(true, overall, `${label} · ${overall}%`); }, controller.signal);
progress.update(overall, label); const uploaded = data.attachments || [];
}); added.push(...uploaded);
added.push(...(data.attachments || [])); state.composeAttachments.push(...uploaded);
renderComposeAttachments();
state.composeDirty = true;
state.composeNeedsRemoteSync = true;
storeLocalDraftSnapshot();
} }
state.composeAttachments.push(...added);
renderComposeAttachments();
state.composeDirty = true;
state.composeNeedsRemoteSync = true;
storeLocalDraftSnapshot();
setComposeUploadProgress(true, 100, trText("Upload complete")); setComposeUploadProgress(true, 100, trText("Upload complete"));
progress.complete(added.length === 1 ? trText("Attachment uploaded") : `${added.length} ${trText("attachments uploaded")}`); setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450);
if (announce) toast(`${added.length} attachment${added.length === 1 ? "" : "s"} added`, "success");
setTimeout(() => setComposeUploadProgress(false), 450);
return added; return added;
} catch (error) { } catch (error) {
setComposeUploadProgress(false); if (error?.name === "AbortError") setComposeUploadProgress(false);
progress.fail(error.message); else {
return []; setComposeUploadProgress(true, 0, error?.message || trText("Upload failed"));
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4800);
}
return added;
} finally { } finally {
if (state.composeUploadController === controller) state.composeUploadController = null;
state.composeUploading = false; state.composeUploading = false;
} }
} }
@@ -978,8 +989,8 @@ async function recoverDraft() {
async function closeComposer() { async function closeComposer() {
clearTimeout(state.draftSaveTimer); clearTimeout(state.draftSaveTimer);
clearTimeout(state.inlineImageCleanupTimer); clearTimeout(state.inlineImageCleanupTimer);
if (state.composeUploadController) state.composeUploadController.abort();
await cleanupUnusedInlineAttachments(); await cleanupUnusedInlineAttachments();
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
if (state.composeNeedsRemoteSync && draftHasContent()) { if (state.composeNeedsRemoteSync && draftHasContent()) {
const saved = await saveCurrentDraft(true, true, true); const saved = await saveCurrentDraft(true, true, true);
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200); if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
+2 -1
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 60-mail-actions-search.js */
async function updateFlag(flag, add) { async function updateFlag(flag, add) {
if (!state.currentDetail) return; if (!state.currentDetail) return;
const detail = state.currentDetail; const detail = state.currentDetail;
@@ -485,6 +485,7 @@ function openMessageContextMenu(x, y, message) {
const flagged = flags.includes("\\Flagged"); const flagged = flags.includes("\\Flagged");
showContextMenu(x, y, [ showContextMenu(x, y, [
{ label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) }, { label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) },
{ label: "Open full screen", action: async () => { await ensureMessageOpen(message); enterReaderFullscreen(); } },
{ label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } }, { label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } },
{ label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } }, { label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } },
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } }, { label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
+274 -274
View File
@@ -1,294 +1,294 @@
/* RustBox UI module: 70-contacts.js */
async function openContacts() {
$("#contacts-dialog").showModal();
await loadContactGroups();
await loadContacts();
}
async function loadContactGroups() { async function openContacts() {
try { $("#contacts-dialog").showModal();
const data = await request("/contact-groups", { method: "GET" }); await loadContactGroups();
state.contactGroups = data.groups || []; await loadContacts();
renderContactGroupFilter(); }
renderContactGroupMembership($("#contact-id").value || null, []);
} catch (error) { toast(error.message, "error"); }
}
function renderContactGroupFilter() { async function loadContactGroups() {
const select = $("#contact-group-filter"); try {
const current = select.value; const data = await request("/contact-groups", { method: "GET" });
select.replaceChildren(new Option("All contacts", "")); state.contactGroups = data.groups || [];
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id)); renderContactGroupFilter();
if ([...select.options].some((option) => option.value === current)) select.value = current; renderContactGroupMembership($("#contact-id").value || null, []);
const hasGroup = Boolean(select.value); } catch (error) { toast(error.message, "error"); }
$("#contact-group-rename").disabled = !hasGroup; }
$("#contact-group-delete").disabled = !hasGroup;
}
async function downloadContactExport() { function renderContactGroupFilter() {
try { const select = $("#contact-group-filter");
const headers = new Headers(); const current = select.value;
if (state.accountId) headers.set("X-Active-Account", state.accountId); select.replaceChildren(new Option("All contacts", ""));
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers }); for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
if (!response.ok) throw new Error(`Export failed (${response.status})`); if ([...select.options].some((option) => option.value === current)) select.value = current;
const blob = await response.blob(); const hasGroup = Boolean(select.value);
const url = URL.createObjectURL(blob); $("#contact-group-rename").disabled = !hasGroup;
const link = document.createElement("a"); $("#contact-group-delete").disabled = !hasGroup;
link.href = url; }
link.download = "contacts.vcf";
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("Calendar exported", "success");
} catch (error) { toast(error.message, "error"); }
}
async function importContactVcard(files) { async function downloadContactExport() {
const file = files?.[0]; try {
if (!file) return; const headers = new Headers();
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error"); if (state.accountId) headers.set("X-Active-Account", state.accountId);
const form = new FormData(); const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
form.append("file", file, file.name || "contacts.vcf"); if (!response.ok) throw new Error(`Export failed (${response.status})`);
try { const blob = await response.blob();
const data = await request("/contacts/import", { method: "POST", body: form }); const url = URL.createObjectURL(blob);
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`); const link = document.createElement("a");
await loadContacts($("#contact-search").value.trim()); link.href = url;
} catch (error) { toast(error.message, "error"); } link.download = "contacts.vcf";
finally { $("#contact-import-file").value = ""; } document.body.append(link);
} link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("Calendar exported", "success");
} catch (error) { toast(error.message, "error"); }
}
async function showContactDuplicates() { async function importContactVcard(files) {
try { const file = files?.[0];
const data = await request("/contacts/duplicates", { method: "GET" }); if (!file) return;
const groups = data.duplicates || []; if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
const list = $("#contact-duplicate-list"); const form = new FormData();
list.replaceChildren(); form.append("file", file, file.name || "contacts.vcf");
if (!groups.length) { try {
list.append(el("div", "empty-list", "No duplicate contacts found")); const data = await request("/contacts/import", { method: "POST", body: form });
} toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
for (const group of groups) { await loadContacts($("#contact-search").value.trim());
const card = el("section", "duplicate-contact-group"); } catch (error) { toast(error.message, "error"); }
card.append(el("strong", "", group.email)); finally { $("#contact-import-file").value = ""; }
const rows = el("div", "duplicate-contact-options"); }
for (const contact of group.contacts || []) {
const row = el("div", "duplicate-contact-row");
const summary = el("div", "duplicate-contact-summary");
summary.append(el("strong", "", contact.name || contact.email));
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
const keep = el("button", "button quiet", "Keep this");
keep.type = "button";
keep.addEventListener("click", async () => {
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
if (!sources.length) return;
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
try {
let target = contact;
for (const source of sources) {
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
}
toast("Contacts merged");
await loadContacts($("#contact-search").value.trim());
await showContactDuplicates();
editContact(target);
} catch (error) { toast(error.message, "error"); }
});
row.append(summary, keep); rows.append(row);
}
card.append(rows); list.append(card);
}
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
} catch (error) { toast(error.message, "error"); }
}
async function loadContacts(query = "") { async function showContactDuplicates() {
try { try {
const groupId = $("#contact-group-filter")?.value || ""; const data = await request("/contacts/duplicates", { method: "GET" });
let contacts; const groups = data.duplicates || [];
if (groupId) { const list = $("#contact-duplicate-list");
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
contacts = data.members || [];
if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
} else {
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
contacts = data.contacts || [];
}
state.contacts = contacts;
renderContacts();
} catch (error) { toast(error.message, "error"); }
}
async function createContactGroup() {
const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
if (!name?.trim()) return;
try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
catch (error) { toast(error.message, "error"); }
}
async function renameContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group) return;
const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
if (!name?.trim() || name.trim() === group.name) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function loadContactMembership(contactId) {
if (!contactId) return renderContactGroupMembership(null, []);
try {
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
} catch (error) { toast(error.message, "error"); }
}
function renderContactGroupMembership(contactId, selectedIds) {
const list = $("#contact-group-membership");
if (!list) return;
const selected = new Set(selectedIds || []);
list.replaceChildren(); list.replaceChildren();
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; } if (!groups.length) {
for (const group of state.contactGroups) { list.append(el("div", "empty-list", "No duplicate contacts found"));
const label = el("label", "checkbox-row");
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
input.addEventListener("change", async () => {
try {
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
await loadContactGroups();
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
});
label.append(input, el("span", "", group.name)); list.append(label);
} }
} for (const group of groups) {
const card = el("section", "duplicate-contact-group");
function renderContacts() { card.append(el("strong", "", group.email));
const list = $("#contact-list"); const rows = el("div", "duplicate-contact-options");
list.replaceChildren(); for (const contact of group.contacts || []) {
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts")); const row = el("div", "duplicate-contact-row");
for (const contact of state.contacts) { const summary = el("div", "duplicate-contact-summary");
const button = el("button", "contact-item"); summary.append(el("strong", "", contact.name || contact.email));
button.type = "button"; summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`; const keep = el("button", "button quiet", "Keep this");
button.addEventListener("click", () => editContact(contact)); keep.type = "button";
list.append(button); keep.addEventListener("click", async () => {
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
if (!sources.length) return;
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
try {
let target = contact;
for (const source of sources) {
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
}
toast("Contacts merged");
await loadContacts($("#contact-search").value.trim());
await showContactDuplicates();
editContact(target);
} catch (error) { toast(error.message, "error"); }
});
row.append(summary, keep); rows.append(row);
}
card.append(rows); list.append(card);
} }
} if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
} catch (error) { toast(error.message, "error"); }
}
function editContact(contact = null) { async function loadContacts(query = "") {
$("#contact-id").value = contact?.id || ""; try {
$("#contact-name").value = contact?.name || ""; const groupId = $("#contact-group-filter")?.value || "";
$("#contact-email").value = contact?.email || ""; let contacts;
$("#contact-emails").value = (contact?.emails || []).join("\n"); if (groupId) {
$("#contact-company").value = contact?.company || ""; const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
$("#contact-phone").value = contact?.phone || ""; contacts = data.members || [];
$("#contact-phones").value = (contact?.phones || []).join("\n"); if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
$("#contact-job-title").value = contact?.job_title || ""; } else {
$("#contact-website").value = contact?.website || ""; const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
$("#contact-birthday").value = contact?.birthday || ""; contacts = data.contacts || [];
$("#contact-address").value = contact?.address || ""; }
$("#contact-addresses").value = (contact?.addresses || []).join("\n"); state.contacts = contacts;
$("#contact-notes").value = contact?.notes || ""; renderContacts();
$("#contact-favorite").checked = Boolean(contact?.is_favorite); } catch (error) { toast(error.message, "error"); }
$("#contact-delete").disabled = !contact; }
$("#contact-compose").disabled = !contact?.email;
$("#contact-photo-upload").disabled = !contact?.id;
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
renderContactPhoto(contact);
loadContactMembership(contact?.id || null);
}
function clearContactPhotoObjectUrl() { async function createContactGroup() {
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl); const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
state.contactPhotoUrl = null; if (!name?.trim()) return;
} try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
catch (error) { toast(error.message, "error"); }
}
async function renderContactPhoto(contact) { async function renameContactGroup() {
clearContactPhotoObjectUrl(); const id = $("#contact-group-filter").value;
const image = $("#contact-photo-preview"); const group = state.contactGroups.find((item) => item.id === id);
const placeholder = $("#contact-photo-placeholder"); if (!group) return;
image.classList.add("hidden"); const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
image.removeAttribute("src"); if (!name?.trim() || name.trim() === group.name) return;
placeholder.classList.remove("hidden"); try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
if (!contact?.id || !contact?.has_photo) return; catch (error) { toast(error.message, "error"); }
try { }
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
if (!response.ok) return;
const blob = await response.blob();
state.contactPhotoUrl = URL.createObjectURL(blob);
image.src = state.contactPhotoUrl;
image.classList.remove("hidden");
placeholder.classList.add("hidden");
} catch (_) { }
}
async function uploadContactPhoto(files) { async function deleteContactGroup() {
const id = $("#contact-id").value; const id = $("#contact-group-filter").value;
const file = files?.[0]; const group = state.contactGroups.find((item) => item.id === id);
if (!id || !file) return; if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; } try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
const form = new FormData(); catch (error) { toast(error.message, "error"); }
form.append("file", file, file.name || "photo"); }
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = true;
$("#contact-photo-delete").disabled = false;
await renderContactPhoto({ id, has_photo: true });
toast("Contact photo saved");
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-photo-file").value = ""; }
}
async function deleteContactPhoto() { async function loadContactMembership(contactId) {
const id = $("#contact-id").value; if (!contactId) return renderContactGroupMembership(null, []);
if (!id) return; try {
try { const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" }); renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
const contact = state.contacts.find((item) => item.id === id); } catch (error) { toast(error.message, "error"); }
if (contact) contact.has_photo = false; }
$("#contact-photo-delete").disabled = true;
await renderContactPhoto({ id, has_photo: false });
toast("Contact photo removed");
} catch (error) { toast(error.message, "error"); }
}
async function saveContact(event) { function renderContactGroupMembership(contactId, selectedIds) {
event.preventDefault(); const list = $("#contact-group-membership");
const payload = { if (!list) return;
id: $("#contact-id").value || null, const selected = new Set(selectedIds || []);
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(), list.replaceChildren();
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(), if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value, for (const group of state.contactGroups) {
is_favorite: $("#contact-favorite").checked, source: "manual", const label = el("label", "checkbox-row");
}; const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); } input.addEventListener("change", async () => {
catch (error) { toast(error.message, "error"); } try {
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
await loadContactGroups();
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
});
label.append(input, el("span", "", group.name)); list.append(label);
} }
}
async function deleteContact() { function renderContacts() {
const id = $("#contact-id").value; const list = $("#contact-list");
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return; list.replaceChildren();
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); } if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
catch (error) { toast(error.message, "error"); } for (const contact of state.contacts) {
const button = el("button", "contact-item");
button.type = "button";
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
button.addEventListener("click", () => editContact(contact));
list.append(button);
} }
}
function composeToContact() { function editContact(contact = null) {
const email = $("#contact-email").value.trim(); $("#contact-id").value = contact?.id || "";
if (!email) return; $("#contact-name").value = contact?.name || "";
$("#contacts-dialog").close(); $("#contact-email").value = contact?.email || "";
openCompose("new"); $("#contact-emails").value = (contact?.emails || []).join("\n");
$("#compose-to").value = email; $("#contact-company").value = contact?.company || "";
} $("#contact-phone").value = contact?.phone || "";
$("#contact-phones").value = (contact?.phones || []).join("\n");
$("#contact-job-title").value = contact?.job_title || "";
$("#contact-website").value = contact?.website || "";
$("#contact-birthday").value = contact?.birthday || "";
$("#contact-address").value = contact?.address || "";
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
$("#contact-notes").value = contact?.notes || "";
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
$("#contact-delete").disabled = !contact;
$("#contact-compose").disabled = !contact?.email;
$("#contact-photo-upload").disabled = !contact?.id;
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
renderContactPhoto(contact);
loadContactMembership(contact?.id || null);
}
function clearContactPhotoObjectUrl() {
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
state.contactPhotoUrl = null;
}
async function renderContactPhoto(contact) {
clearContactPhotoObjectUrl();
const image = $("#contact-photo-preview");
const placeholder = $("#contact-photo-placeholder");
image.classList.add("hidden");
image.removeAttribute("src");
placeholder.classList.remove("hidden");
if (!contact?.id || !contact?.has_photo) return;
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
if (!response.ok) return;
const blob = await response.blob();
state.contactPhotoUrl = URL.createObjectURL(blob);
image.src = state.contactPhotoUrl;
image.classList.remove("hidden");
placeholder.classList.add("hidden");
} catch (_) { }
}
async function uploadContactPhoto(files) {
const id = $("#contact-id").value;
const file = files?.[0];
if (!id || !file) return;
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; }
const form = new FormData();
form.append("file", file, file.name || "photo");
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = true;
$("#contact-photo-delete").disabled = false;
await renderContactPhoto({ id, has_photo: true });
toast("Contact photo saved");
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-photo-file").value = ""; }
}
async function deleteContactPhoto() {
const id = $("#contact-id").value;
if (!id) return;
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = false;
$("#contact-photo-delete").disabled = true;
await renderContactPhoto({ id, has_photo: false });
toast("Contact photo removed");
} catch (error) { toast(error.message, "error"); }
}
async function saveContact(event) {
event.preventDefault();
const payload = {
id: $("#contact-id").value || null,
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
is_favorite: $("#contact-favorite").checked, source: "manual",
};
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteContact() {
const id = $("#contact-id").value;
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return;
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
catch (error) { toast(error.message, "error"); }
}
function composeToContact() {
const email = $("#contact-email").value.trim();
if (!email) return;
$("#contacts-dialog").close();
openCompose("new");
$("#compose-to").value = email;
}
+1 -1
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 71-calendar.js */
function startOfCalendarDay(date) { function startOfCalendarDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()); return new Date(date.getFullYear(), date.getMonth(), date.getDate());
} }
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more