From a643e7e3ab3450a289b755906ad5fd90fd3f8e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 30 Oct 2026 12:06:44 +0100 Subject: [PATCH] v0.0.5 --- CHANGELOG.md | 12 + Cargo.lock | 2 +- Cargo.toml | 2 +- NOTICE.md | 2 +- README.md | 7 +- RELEASE-MANIFEST.txt | 13 +- build.rs | 33 +- config.dev.toml | 1 - config.example.toml | 4 +- docs/ROADMAP_GAPS.md | 107 -- docs/ROUNDCUBE_SKIN_VALIDATION.md | 20 - docs/RUSTBOX_PLAN.md | 1357 ------------------------ docs/releases/v0.0.1.md | 26 - docs/releases/v0.0.2.md | 21 - docs/releases/v0.0.3.md | 14 - docs/releases/v0.0.4.md | 11 - scripts/sync-upstream.sh | 3 - src/auth/csrf.rs | 31 +- src/auth/imap_auth.rs | 48 +- src/auth/session.rs | 63 +- src/auth/user_data.rs | 18 +- src/calendar/ics.rs | 12 - src/config.rs | 64 -- src/db/calendar.rs | 46 - src/db/contact_groups.rs | 15 +- src/db/contacts.rs | 91 +- src/db/drafts.rs | 136 ++- src/db/folders.rs | 199 ++-- src/db/header_sync.rs | 9 - src/db/messages.rs | 481 ++++++--- src/db/notification_preferences.rs | 39 +- src/db/pool.rs | 26 +- src/db/special_folders.rs | 15 +- src/db/tags.rs | 101 +- src/email_theme.rs | 71 +- src/error.rs | 20 +- src/imap/client.rs | 83 +- src/imap/connection.rs | 21 +- src/imap/error.rs | 10 +- src/imap/mock.rs | 57 +- src/imap/parse.rs | 32 +- src/imap/pool.rs | 20 +- src/imap/types.rs | 70 +- src/main.rs | 10 - src/pgp/discovery.rs | 2 +- src/pgp/mod.rs | 15 - src/realtime/events.rs | 29 +- src/realtime/idle.rs | 96 +- src/realtime/sync.rs | 24 +- src/realtime/ws.rs | 30 +- src/routes/attachments.rs | 15 - src/routes/auth.rs | 27 - src/routes/calendar.rs | 61 +- src/routes/contact_groups.rs | 16 - src/routes/contacts.rs | 163 +-- src/routes/diagnostics.rs | 24 - src/routes/display_preferences.rs | 2 - src/routes/drafts.rs | 22 +- src/routes/folder_mgmt.rs | 30 - src/routes/folders.rs | 24 +- src/routes/health.rs | 9 - src/routes/identities.rs | 5 - src/routes/messages/mod.rs | 158 +-- src/routes/messages/types.rs | 13 - src/routes/mod.rs | 53 +- src/routes/notification_preferences.rs | 2 - src/routes/quota.rs | 13 +- src/routes/search.rs | 101 +- src/routes/send.rs | 49 +- src/routes/sieve.rs | 9 - src/routes/tags.rs | 32 - src/routes/tests.rs | 73 +- src/routes/unsubscribe.rs | 4 +- src/search/engine.rs | 53 +- src/sieve/client.rs | 13 +- src/sieve/error.rs | 2 +- src/smtp/builder.rs | 2 - src/smtp/client.rs | 28 - src/smtp/error.rs | 10 +- src/smtp/mock.rs | 22 +- src/smtp/types.rs | 53 +- src/ui.rs | 26 +- src/web/css/00-tokens.css | 2 +- src/web/css/30-mail.css | 114 +- src/web/css/40-compose.css | 2 +- src/web/css/50-settings.css | 36 +- src/web/css/55-roundcube-skins.css | 13 +- src/web/css/56-interface-styles.css | 11 +- src/web/css/60-responsive.css | 18 +- src/web/css/components.css | 1 - src/web/index.html | 46 +- src/web/js/00-bootstrap.js | 18 +- src/web/js/10-core.js | 91 +- src/web/js/20-preferences-auth.js | 2 +- src/web/js/30-folders-tags.js | 874 +++++++-------- src/web/js/40-messages-reader.js | 93 +- src/web/js/50-compose.js | 61 +- src/web/js/60-mail-actions-search.js | 3 +- src/web/js/70-contacts.js | 548 +++++----- src/web/js/71-calendar.js | 2 +- src/web/js/72-openpgp-identities.js | 2 +- src/web/js/80-settings-realtime.js | 125 ++- src/web/js/90-sieve.js | 4 +- src/web/js/95-bindings-boot.js | 35 +- tests/roundcube_skin_contract.py | 6 +- tests/test_release_005_contract.py | 52 + 106 files changed, 2223 insertions(+), 4669 deletions(-) delete mode 100644 docs/ROADMAP_GAPS.md delete mode 100644 docs/ROUNDCUBE_SKIN_VALIDATION.md delete mode 100644 docs/RUSTBOX_PLAN.md delete mode 100644 docs/releases/v0.0.1.md delete mode 100644 docs/releases/v0.0.2.md delete mode 100644 docs/releases/v0.0.3.md delete mode 100644 docs/releases/v0.0.4.md create mode 100644 tests/test_release_005_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e68eb..fee0a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 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 - 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. diff --git a/Cargo.lock b/Cargo.lock index a52c607..db91692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2957,7 +2957,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.0.4" +version = "0.0.5" dependencies = [ "async-imap", "async-native-tls", diff --git a/Cargo.toml b/Cargo.toml index 69b5d84..3857bbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.0.4" +version = "0.0.5" edition = "2024" rust-version = "1.88" default-run = "rustbox" diff --git a/NOTICE.md b/NOTICE.md index c07a4f7..536478c 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,6 +1,6 @@ # 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` - Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d` diff --git a/README.md b/README.md index 59fb6dd..be8f1de 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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. -## 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 @@ -141,7 +138,7 @@ Initialize Git after extracting the release archive: ```bash git init git add . -git commit -m "RustBox 0.0.4 release" +git commit -m "RustBox 0.0.5 release" ./scripts/setup-upstream.sh ``` diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index f26c099..f9e0dd4 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,4 +1,4 @@ -RustBox 0.0.4 source release +RustBox 0.0.5 source release Runtime/source model: - one root Cargo package @@ -19,7 +19,7 @@ Oxi tracking: - Oxi frontend changes are reviewed and manually ported to src/web/ - docs/UI.md documents the browser UI/build conventions -Current 0.0.4 release additions: +Current 0.0.5 release additions: - attachment left-click defaults to safe in-app preview for supported types - attachment right-click exposes explicit Preview and Download actions - oversized preview attempts offer direct download instead of ending with a warning @@ -64,3 +64,12 @@ Current 0.0.4 release additions: Validation note: - JavaScript, JSON, TOML, SVG, shell syntax, UI selector/icon integrity and SQLite migrations were validated in the release environment. - A full cargo build/test was not executed in this environment because cargo/rustc are not installed; run ./dev.sh or cargo test on a Rust toolchain host before deployment. + +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 diff --git a/build.rs b/build.rs index 3610c9b..cfd56c0 100644 --- a/build.rs +++ b/build.rs @@ -14,18 +14,11 @@ fn fnv1a64(parts: &[&[u8]]) -> u64 { hash } -fn bundle_manifest( - root: &Path, - directory: &Path, - manifest: &Path, - extension: &str, - banner: &str, -) -> String { +fn bundle_manifest(root: &Path, directory: &Path, manifest: &Path, extension: &str) -> String { println!("cargo:rerun-if-changed={}", manifest.display()); let manifest_text = fs::read_to_string(manifest) .unwrap_or_else(|error| panic!("{}: {error}", manifest.display())); - let mut bundle = String::from(banner); - bundle.push('\n'); + let mut bundle = String::new(); for raw in manifest_text.lines() { let name = raw.trim(); @@ -48,9 +41,9 @@ fn bundle_manifest( println!("cargo:rerun-if-changed={}", path.display()); let source = fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); - bundle.push_str("\n/* ---- "); - bundle.push_str(name); - bundle.push_str(" ---- */\n"); + if !bundle.is_empty() { + bundle.push('\n'); + } bundle.push_str(&source); if !source.ends_with('\n') { bundle.push('\n'); @@ -75,20 +68,8 @@ fn main() { println!("cargo:rerun-if-changed={}", path.display()); } - let css_bundle = bundle_manifest( - &root, - &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 css_bundle = bundle_manifest(&root, &css_dir, &css_dir.join("manifest.txt"), ".css"); + let js_bundle = bundle_manifest(&root, &js_dir, &js_dir.join("manifest.txt"), ".js"); let asset_id = fnv1a64(&[css_bundle.as_bytes(), js_bundle.as_bytes()]); println!("cargo:rustc-env=RUSTBOX_ASSET_ID={asset_id:016x}"); diff --git a/config.dev.toml b/config.dev.toml index 1c53b76..1d2a17b 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -27,4 +27,3 @@ sieve_allow_insecure_auth = false sieve_timeout_secs = 10 trusted_proxies = "127.0.0.1,::1" - diff --git a/config.example.toml b/config.example.toml index 183013c..30172e4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,4 +1,4 @@ -# RustBox 0.0.4 +# RustBox 0.0.5 host = "127.0.0.1" port = 3001 environment = "production" @@ -7,7 +7,7 @@ default_theme = "system" # Global interface style used by the login screen and by accounts set to # "Global default". Presets include gmail, proton, zoho, outlook, fastmail, # apple-mail, yahoo, hey, nord, minimal and Roundcube variants. -default_interface_skin = "rustbox" +default_interface_skin = "gmail" data_dir = "/var/lib/rustbox" session_timeout_hours = 24 remember_me_enabled = true diff --git a/docs/ROADMAP_GAPS.md b/docs/ROADMAP_GAPS.md deleted file mode 100644 index 0dee02c..0000000 --- a/docs/ROADMAP_GAPS.md +++ /dev/null @@ -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. diff --git a/docs/ROUNDCUBE_SKIN_VALIDATION.md b/docs/ROUNDCUBE_SKIN_VALIDATION.md deleted file mode 100644 index 36084a7..0000000 --- a/docs/ROUNDCUBE_SKIN_VALIDATION.md +++ /dev/null @@ -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. diff --git a/docs/RUSTBOX_PLAN.md b/docs/RUSTBOX_PLAN.md deleted file mode 100644 index 80eed01..0000000 --- a/docs/RUSTBOX_PLAN.md +++ /dev/null @@ -1,1357 +0,0 @@ -# RustBox 0.0.1 — plan funkcjonalny bazowego wydania - -> **Status:** wersja wewnętrzna / przed publikacją -> **Wersja:** `0.0.1` -> **Cel:** doprowadzić RustBox do poziomu funkcjonalnego zbliżonego do Roundcube wraz z typowymi funkcjami zapewnianymi przez jego oficjalne i popularne wtyczki — przed pierwszym publicznym wydaniem. - -## 0. wielojezykowosc, -## wstepnie angielski i polski, wszystko jak w rustproxy, czyli w plkikach jezykow, wybor nalezy do usera - - - -## 1. Zasada wydania 0.0.1 - -RustBox `0.0.1` jest wydaniem bazowym i nie jest przeznaczony do publikacji przed zakończeniem całego poniższego zakresu. - -Wszystkie funkcje opisane w tym dokumencie należą do `0.0.1`. Kolejne sekcje są etapami realizacyjnymi wewnątrz tej samej wersji, a nie osobnymi wydaniami. - -Podstawowe założenia: - -- jedna aplikacja Rust; -- jeden proces runtime; -- brak Node.js, Bun, React, Next.js i TypeScriptu w źródłach produktu; -- interfejs HTML/CSS/vanilla JavaScript osadzony w binarce; -- konfiguracja serwerów IMAP/SMTP/ManageSieve wyłącznie po stronie serwera w pliku konfiguracyjnym; -- ekran logowania zawiera tylko dane użytkownika potrzebne do uwierzytelnienia, domyślnie e-mail i hasło; -- obsługa IMAP, SMTP, ManageSieve, WebSocket, sesji i UI w jednym procesie; -- zachowanie możliwości śledzenia rozwoju Oxi i portowania jego nowych funkcji do RustBox; -- brak automatycznego przywracania starego frontendu Oxi podczas synchronizacji z upstreamem; -- własny spójny interfejs RustBox inspirowany układem klasycznego webmaila typu Roundcube; -- brak gradientów; -- brak emoji i znaków Unicode udających ikony; -- wyłącznie spójne, popularne ikony SVG; -- ciemny, jasny i systemowy motyw; -- pełna obsługa desktopu i urządzeń mobilnych. - ---- - -# 2. Etap A — pełny fundament klienta IMAP - -To jest najwyższy priorytet. RustBox musi być pełnoprawnym klientem pocztowym zanim rozbudowane zostaną funkcje dodatkowe. - -## 2.1. Foldery IMAP - -Pełny menedżer folderów musi obsługiwać: - -- hierarchiczną listę folderów zgodnie z delimiterem zgłaszanym przez IMAP; -- tworzenie folderów; -- tworzenie podfolderów; -- zmianę nazwy folderu; -- usuwanie folderu z potwierdzeniem; -- subskrypcję folderu; -- anulowanie subskrypcji; -- przenoszenie folderów, jeśli serwer i protokół to umożliwiają; -- opróżnianie folderu; -- oznaczenie wszystkich wiadomości jako przeczytane; -- liczniki wiadomości wszystkich i nieprzeczytanych; -- automatyczne odświeżanie liczników; -- obsługę IMAP IDLE; -- specjalne foldery; -- foldery współdzielone; -- namespaces IMAP; -- właściwe kodowanie nazw folderów; -- Unicode w nazwach folderów; -- możliwość ustawienia folderów specjalnych przez użytkownika. - -### Foldery specjalne - -Użytkownik musi móc wybrać folder dla: - -- Sent; -- Drafts; -- Trash; -- Junk/Spam; -- Archive. - -Układ ustawień: - -```text -Settings -└── Folders - ├── Subscriptions - ├── Special folders - │ ├── Sent - │ ├── Drafts - │ ├── Trash - │ ├── Junk - │ └── Archive - ├── Create folder - ├── Rename - ├── Delete - └── Folder properties -``` - -## 2.2. Operacje na wiadomościach - -RustBox musi obsługiwać: - -- otwarcie wiadomości; -- odpowiedź; -- odpowiedź wszystkim; -- przekazanie wiadomości; -- przekazanie jako załącznik; -- oznaczenie przeczytane/nieprzeczytane; -- flagowanie; -- usunięcie flagi; -- przeniesienie; -- kopiowanie; -- archiwizację; -- usuwanie; -- przeniesienie do spamu; -- oznaczenie jako nie-spam; -- zaznaczanie wielu wiadomości; -- zaznaczanie wszystkich wyników; -- operacje grupowe; -- drag & drop między folderami; -- Undo po możliwych do cofnięcia operacjach; -- zachowanie pozycji scrolla i stanu listy po powrocie do folderu. - -## 2.3. Menu kontekstowe — prawy przycisk myszy - -### Wiadomość - -```text -Open -Reply -Reply all -Forward -Forward as attachment ---- -Mark read / unread -Flag / unflag ---- -Move to… -Copy to… -Archive -Delete -Spam / Not spam ---- -Create filter from message -Add sender to contacts -Search messages from sender ---- -Show headers -Show source -Download .eml -Print -``` - -### Folder - -```text -Open -Open in new view ---- -Mark all read -Create subfolder -Rename -Subscribe / Unsubscribe -Empty -Delete ---- -Properties -Permissions -``` - -### Załącznik - -```text -Open -Download -Save -Download all as ZIP -``` - -## 2.4. Wątki - -Obsługa wątków musi obejmować: - -- widok threaded i flat; -- poprawne użycie `Message-ID`, `In-Reply-To`, `References`; -- rozwijanie i zwijanie wątku; -- automatyczne rozwijanie zgodnie z preferencją użytkownika; -- przechodzenie między wiadomościami w wątku; -- liczbę wiadomości w wątku; -- status unread/flagged na poziomie wątku; -- poprawne zachowanie po przeniesieniu części wątku do innego folderu. - ---- - -# 3. Etap B — pełny podgląd wiadomości - -## 3.1. Podstawowe nagłówki - -Domyślnie pokazywane: - -- From; -- To; -- Cc; -- Date; -- Subject. - -## 3.2. Rozszerzone nagłówki - -Po wybraniu `Show details` / `Show headers`: - -- From; -- Sender; -- Reply-To; -- To; -- Cc; -- Bcc, jeśli wiadomość lokalnie to umożliwia; -- Date; -- Message-ID; -- In-Reply-To; -- References; -- Return-Path; -- Delivered-To; -- Envelope-To, jeśli występuje; -- List-ID; -- List-Unsubscribe; -- User-Agent; -- X-Mailer; -- Authentication-Results; -- DKIM-Signature; -- Received; -- Content-Type; -- Content-Transfer-Encoding; -- pozostałe nagłówki w trybie pełnym. - -## 3.3. Surowa wiadomość - -RustBox musi oferować: - -- `Show source`; -- wyświetlenie dokładnego RFC822/MIME source; -- brak przepuszczania source przez renderer HTML; -- pobranie jako `.eml`; -- kopiowanie Message-ID; -- kopiowanie adresów; -- przełączanie HTML / plain text; -- drukowanie; -- obsługę `message/rfc822`. - -## 3.4. HTML wiadomości - -Renderer wiadomości HTML musi: - -- sanitizować HTML; -- sanitizować CSS; -- blokować skrypty; -- blokować niebezpieczne SVG; -- blokować zewnętrzne zasoby domyślnie; -- obsługiwać CID; -- pozwalać użytkownikowi jednorazowo załadować obrazy; -- mieć politykę: nigdy / kontakty / pytaj / zawsze; -- izolować treść wiadomości od interfejsu RustBox; -- poprawnie pokazywać tekstową alternatywę; -- nie uruchamiać aktywnej zawartości wiadomości. - -## 3.5. Cytaty - -- zwijanie cytowanej treści; -- rozwinięcie na żądanie; -- sensowna detekcja poprzednich odpowiedzi; -- obsługa cytowania plain text i HTML. - ---- - -# 4. Etap C — pełny composer - -## 4.1. Pola wiadomości - -Composer musi obsługiwać: - -- From / identity; -- To; -- Cc; -- Bcc; -- Reply-To; -- Subject; -- priorytet wiadomości; -- żądanie potwierdzenia przeczytania; -- opcjonalne DSN, jeśli backend SMTP pozwala; -- własne dodatkowe nagłówki skonfigurowane przez administratora. - -## 4.2. Tryby edycji - -- HTML; -- plain text; -- Markdown; -- przełączanie trybu z ostrzeżeniem, jeśli może dojść do utraty formatowania. - -## 4.3. Edytor HTML - -Toolbar: - -- Bold; -- Italic; -- Underline; -- Strike; -- font; -- rozmiar; -- kolor tekstu; -- wyrównanie; -- lista numerowana; -- lista punktowana; -- cytat; -- link; -- obraz; -- usuń formatowanie; -- Undo; -- Redo. - -Edytor nie może wymagać Node.js/Bun w runtime. Jeżeli wykorzystywana jest biblioteka edytora, ma być vendored jako statyczny asset i włączona do binarki. - -## 4.4. Drafty - -- automatyczny zapis draftu; -- konfigurowalny interwał; -- informacja o ostatnim zapisie; -- odzyskiwanie po utracie połączenia; -- odzyskiwanie po zamknięciu karty; -- ostrzeżenie przed utratą niezapisanego tekstu; -- draft recovery po błędzie SMTP; -- zapis do wybranego folderu Drafts. - -## 4.5. Załączniki - -- wybór pliku; -- drag & drop; -- wklejanie ze schowka; -- pasek postępu uploadu; -- możliwość usunięcia przed wysłaniem; -- limity rozmiaru; -- kontrola MIME; -- bezpieczne nazwy plików; -- obsługa dużych plików bez niepotrzebnego trzymania całości w pamięci; -- tymczasowy magazyn izolowany per sesja; -- automatyczne sprzątanie plików tymczasowych; -- pobranie wszystkich załączników jako ZIP. - -## 4.6. Obrazy inline - -Proces: - -```text -Insert image -→ upload -→ walidacja MIME -→ zapis tymczasowy -→ CID attachment -→ -``` - -Należy również obsłużyć: - -- wklejenie obrazu ze schowka; -- przeciągnięcie obrazu do composera; -- usuwanie nieużywanego CID attachmentu; -- skalowanie po stronie UI bez destrukcji oryginalnego pliku. - -## 4.7. Attachment reminder - -Jeżeli treść sugeruje załącznik, np. „w załączniku”, „załączam”, a wiadomość nie ma załącznika, RustBox powinien ostrzec przed wysłaniem. - ---- - -# 5. Etap D — tożsamości i stopki - -## 5.1. Tożsamości - -Użytkownik może posiadać wiele identities: - -```text -Settings -└── Identities - ├── Personal - ├── Work - └── support@example.com -``` - -Każda identity zawiera: - -- Display name; -- Email; -- Reply-To; -- Bcc; -- Organization; -- podpis plain text; -- podpis HTML; -- ustawienie domyślnej identity. - -## 5.2. Automatyczny wybór identity - -Przy odpowiedzi RustBox powinien dobierać identity na podstawie: - -- To; -- Cc; -- Delivered-To; -- Envelope-To; -- aliases użytkownika; -- innych dostępnych nagłówków odbiorcy. - -## 5.3. Stopki / signatures - -Opcje: - -- plain text; -- HTML; -- automatyczne wstawianie; -- tylko nowa wiadomość; -- odpowiedź; -- forward; -- pozycja nad cytatem; -- pozycja pod cytatem; -- osobna stopka dla każdej identity; -- możliwość wyłączenia stopki per wiadomość. - ---- - -# 6. Etap E — ManageSieve, szybkie filtry i autoresponder - -## 6.1. Menedżer filtrów - -Widok: - -```text -Filters -├── Spam -├── GitLab -├── Mailing lists -└── Invoices -``` - -Funkcje: - -- lista skryptów; -- aktywny skrypt; -- tworzenie; -- edycja; -- usuwanie; -- zmiana nazwy; -- aktywowanie; -- dezaktywowanie; -- walidacja; -- raw editor; -- wizualny rule builder; -- podgląd wynikowego Sieve; -- wykrywanie capabilities serwera; -- ostrzeganie o niedostępnych rozszerzeniach. - -## 6.2. Warunki filtrów - -- From; -- To; -- Cc; -- Recipient; -- Subject; -- dowolny header; -- Body, jeśli capability pozwala; -- size; -- date; -- List-ID; -- address; -- domain; -- exists; -- spam headers; -- kombinacje AND / OR; -- negacja; -- kolejność reguł; -- stop processing. - -## 6.3. Akcje filtrów - -- Move; -- Copy; -- Discard; -- Reject; -- Redirect; -- Keep; -- Mark read; -- Add flag; -- Remove flag; -- Vacation; -- Stop. - -## 6.4. Create filter from message - -Prawoklik wiadomości: - -```text -Create filter → - From this sender - From this domain - To this address - Subject contains… - Mailing list -``` - -Przykład: - -```text -IF From = invoices@example.com -THEN Move to Invoices -``` - -Formularz musi być automatycznie uzupełniony na podstawie wybranej wiadomości. - -## 6.5. Autoresponder / Vacation - -Pola: - -- enabled; -- from; -- until; -- subject; -- message; -- days between replies; -- zakres dat; -- nie odpowiadaj mailing listom; -- nie odpowiadaj automatom; -- dodatkowe warunki dostępności. - -W trybie Advanced użytkownik powinien móc zobaczyć wygenerowany skrypt Sieve. - ---- - -# 7. Etap F — kontakty i książka adresowa - -## 7.1. Funkcje podstawowe - -- lista kontaktów; -- grupy; -- wyszukiwanie; -- autocomplete; -- import vCard; -- export vCard; -- zdjęcia kontaktów; -- duplicate detection; -- dodanie nadawcy z wiadomości; -- edycja; -- usuwanie; -- wiele adresów e-mail; -- wiele numerów telefonu. - -## 7.2. Dane kontaktu - -- Name; -- Surname; -- Display name; -- Email(s); -- Phone(s); -- Organization; -- Job title; -- Address; -- Website; -- Notes; -- Birthday; -- Photo. - -## 7.3. Upload zdjęcia kontaktu - -- upload pliku; -- walidacja MIME; -- limit rozmiaru; -- bezpieczne dekodowanie; -- miniatura; -- usunięcie zdjęcia; -- zachowanie oryginalnych proporcji. - -## 7.4. Akcje na adresie - -Po kliknięciu adresu: - -```text -Compose -Add to contacts -Copy address -Search messages -Create filter -``` - -## 7.5. Źródła danych - -Docelowo: - -- lokalna książka adresowa; -- CardDAV; -- LDAP read-only; -- LDAP read-write, jeżeli konfiguracja na to pozwala. - ---- - -# 8. Etap G — wyszukiwanie - -## 8.1. Szybkie wyszukiwanie - -Globalne pole: - -```text -Search mail… -``` - -## 8.2. Wyszukiwanie zaawansowane - -- From; -- To; -- Cc; -- Subject; -- Body; -- Date from/to; -- Folder; -- Has attachment; -- Unread; -- Flagged; -- Size. - -## 8.3. Składnia wyszukiwania - -Przykłady: - -```text -from:john@example.com -subject:invoice -has:attachment -is:unread -folder:Archive -after:2026-01-01 -``` - -## 8.4. Zapisane wyszukiwania - -- zapis kryteriów; -- nazwa użytkownika; -- szybkie uruchomienie; -- usuwanie; -- edycja. - -## 8.5. Lokalny indeks - -Tantivy powinien indeksować przyrostowo: - -- nadawcę; -- odbiorców; -- temat; -- treść; -- datę; -- folder; -- informacje o załącznikach; -- Message-ID; -- wybrane nagłówki. - -Indeks musi pozostawać spójny z IMAP po: - -- restartach; -- reconnect; -- przenoszeniu; -- usuwaniu; -- zmianie flag; -- UIDVALIDITY; -- zmianie schematu indeksu. - ---- - -# 9. Etap H — OpenPGP / PGP - -RustBox nie implementuje własnych algorytmów kryptograficznych. Należy użyć sprawdzonej biblioteki. - -## 9.1. Zarządzanie kluczami - -```text -Settings -└── Encryption - └── OpenPGP -``` - -Funkcje: - -- Generate key; -- Import private key; -- Import public key; -- Export public key; -- Export private key, jeśli polityka na to pozwala; -- Delete key; -- Change passphrase; -- fingerprint; -- przypisanie klucza do identity. - -## 9.2. Wysyłanie - -- Sign message; -- Encrypt message; -- Sign + encrypt; -- wybór klucza odbiorcy; -- ostrzeżenie o braku klucza; -- możliwość dołączenia public key. - -## 9.3. Odbieranie - -- Verify signature; -- Decrypt message; -- pokazanie tożsamości podpisującego; -- pokazanie fingerprintu; -- status podpisu; -- status szyfrowania; -- czytelne ostrzeżenie o niepoprawnym podpisie. - -## 9.4. Discovery - -Docelowo: - -- WKD; -- HKP; -- lookup po odbiorcy. - -## 9.5. Ochrona kluczy - -- prywatne klucze nie mogą trafić do logów; -- nie mogą trafić do indeksu wyszukiwania; -- muszą być chronione oddzielnie od sesji przeglądarki; -- hasła kluczy nie mogą być trwałe w plaintext; -- operacje kryptograficzne muszą mieć osobny model zagrożeń. - ---- - -# 10. Etap I — zaawansowane możliwości IMAP - -RustBox powinien działać capability-driven. - -Obsługiwane capability i zachowanie: - -| Capability | RustBox | -|---|---| -| IDLE | natychmiastowe nowe wiadomości | -| MOVE | natywne przenoszenie | -| UIDPLUS | poprawne UID | -| CONDSTORE | wydajna kontrola zmian | -| QRESYNC | szybka resynchronizacja | -| SORT | sortowanie serwerowe | -| THREAD | wątki | -| NAMESPACE | namespaces | -| SPECIAL-USE | Sent/Junk/Trash/etc. | -| QUOTA | quota użytkownika | -| ACL | prawa folderów | -| LIST-EXTENDED | rozszerzona lista folderów | -| UTF8 | międzynarodowe nazwy | -| custom keywords | tagi / dodatkowe flagi | - -Jeśli capability nie istnieje, UI nie może udawać działania funkcji. - ---- - -# 11. Etap J — shared folders i ACL - -Ustawienia folderu: - -```text -Permissions -``` - -Przykładowe prawa: - -```text -user@example.com - lookup - read - seen - write - insert - post - create - delete - administer -``` - -Funkcje: - -- odczyt ACL; -- modyfikacja ACL; -- usuwanie wpisów; -- inherited/default rights, jeśli serwer je wspiera; -- shared folders; -- global folders; -- czytelne rozróżnienie folderów własnych i współdzielonych. - ---- - -# 12. Etap K — funkcjonalne odpowiedniki pluginów Roundcube - -RustBox nie kopiuje PHP plugin API. Funkcje mają być natywnymi modułami jednej aplikacji. - -| Funkcja z ekosystemu Roundcube | Odpowiednik w RustBox | -|---|---| -| ACL | ACL folderów | -| Archive | archiwizacja | -| Attachment reminder | wykrycie braku deklarowanego załącznika | -| Enigma | OpenPGP | -| Hide blockquote | zwijanie cytatów | -| Identity select | automatyczna tożsamość | -| ManageSieve | filtry Sieve | -| Mark as junk | Spam / Not spam | -| New mail notifier | powiadomienia + dźwięk | -| Password | zmiana hasła przez konfigurowalny backend | -| Show additional headers | rozszerzone nagłówki | -| Subscriptions option | subskrypcje folderów | -| Userinfo | informacje o koncie | -| vCard attachments | import kontaktu z załącznika | -| ZIP download | wszystkie załączniki jako ZIP | -| Additional message headers | konfigurowalne nagłówki wychodzące | -| Autologout | timeout sesji | -| Reconnect | automatyczne odzyskiwanie sesji IMAP | -| New user identity | inicjalizacja identity | -| New user dialog | onboarding | -| Virtuser | mapowanie login → adres | -| Debug logger | diagnostyka | - -Funkcje techniczne typu osobnych storage pluginów mają być implementowane jako wewnętrzne moduły RustBox, a nie jako osobne pluginy runtime. - ---- - -# 13. Etap L — Spam / Junk - -Prawoklik: - -```text -Mark as spam -Mark as not spam -``` - -Backend musi mieć warstwę adaptera umożliwiającą administratorowi wybór mechanizmu: - -- IMAP move; -- Sieve; -- rspamd HTTP; -- SpamAssassin command; -- custom command. - -UI nie powinno zależeć od konkretnego backendu antyspamowego. - ---- - -# 14. Etap M — powiadomienia - -- Browser desktop notifications; -- dźwięk; -- unread badge; -- new mail indicator; -- ustawienia per folder; -- Do Not Disturb; -- możliwość całkowitego wyłączenia; -- IMAP IDLE → backend → WebSocket → browser. - ---- - -# 15. Etap N — kalendarz i groupware - -Całość nadal należy do `0.0.1`, ale implementacja następuje po stabilizacji poczty. - -## 15.1. Kalendarz - -Widoki: - -```text -Calendar -├── Month -├── Week -├── Day -└── Agenda -``` - -Funkcje: - -- tworzenie wydarzenia; -- edycja; -- usuwanie; -- iCalendar; -- import; -- export; -- zaproszenia; -- accept; -- decline; -- tentative; -- recurrence; -- reminders; -- timezone; -- shared calendars; -- CalDAV. - -## 15.2. CardDAV - -- synchronizacja kontaktów; -- obsługa wielu książek; -- konflikty edycji; -- retry; -- usuwanie; -- status synchronizacji. - ---- - -# 16. Etap O — ustawienia użytkownika - -Docelowa struktura: - -```text -Settings - -General -Appearance -Mailbox -Message view -Composing -Identities -Signatures -Folders -Filters -Autoresponder -Contacts -Calendar -Encryption -Notifications -Privacy -Security -Sessions -Account -Diagnostics -About -``` - -## 16.1. Appearance - -- Light / Dark / System; -- density; -- font size; -- message list row height; -- preview: right / bottom / disabled; -- folder pane width; -- message list width; -- show avatars; -- show attachment icons; -- show flags; -- date format; -- time format; -- locale; -- UI scale; -- zapamiętanie preferencji użytkownika. - -## 16.2. Mailbox - -- messages per page; -- threaded / flat; -- default sort; -- mark read after N seconds; -- auto-expand threads; -- delete behavior; -- archive behavior; -- trash behavior; -- refresh / IDLE; -- domyślne foldery specjalne. - -## 16.3. Message view - -- HTML / Plain preference; -- remote images: never / contacts / ask / always; -- show full headers; -- collapse quotes; -- default charset fallback; -- print behavior; -- opening attachments. - -## 16.4. Composing - -- HTML / Text / Markdown; -- autosave interval; -- default font; -- reply above/below quote; -- quote style; -- forward inline/as attachment; -- attachment reminder; -- spellcheck; -- default identity; -- signature behavior; -- default priority; -- read receipt behavior. - ---- - -# 17. Etap P — funkcje codzienne, które łatwo przeoczyć - -RustBox `0.0.1` musi również objąć: - -- draft recovery; -- Undo po usunięciu/przeniesieniu; -- multi-select; -- select all; -- select all search results; -- keyboard shortcuts; -- pełną obsługę klawiaturą; -- print; -- `.eml` upload; -- `.eml` download; -- RFC822 attachments; -- `message/rfc822`; -- TNEF / `winmail.dat`, jeśli technicznie wykonalne przy zachowaniu bezpieczeństwa; -- DSN; -- MDN / read receipts; -- SMTPUTF8; -- IDN; -- Unicode headers; -- Unicode filenames; -- poprawne RFC2047; -- mailing-list headers; -- List-Unsubscribe; -- ZIP załączników; -- quota warnings; -- spam indicators; -- message priority; -- custom flags / labels; -- autocomplete kontaktów; -- vCard attachments; -- iCal attachments; -- automatyczny reconnect sesji IMAP; -- zachowanie filtrów i scrolla; -- poprzednia/następna wiadomość; -- obsługa dużych wiadomości; -- obsługa dużych attachmentów; -- retry po błędach sieci; -- czytelny offline/error state. - ---- - -# 18. Etap Q — bezpieczeństwo - -Bezpieczeństwo jest realizowane równolegle z każdą funkcją i nie może zostać odłożone na koniec. - -Wymagane obszary: - -- HTML sanitizer; -- CSS sanitizer; -- SVG sanitizer; -- remote images; -- tracking pixels; -- `data:` URI; -- CID; -- MIME nesting; -- malformed headers; -- CRLF/header injection; -- attachment filename traversal; -- archiwa; -- zip bombs; -- huge MIME messages; -- SSRF; -- cross-account authorization; -- CSRF; -- XSS; -- session fixation; -- session hijacking; -- secure cookies; -- SameSite; -- TLS verification; -- host name verification; -- rate limiting logowania; -- bezpieczne przechowywanie tokenów; -- brak haseł w logach; -- brak tokenów w logach; -- brak prywatnych kluczy PGP w logach; -- brak treści wiadomości w logach poza jawnie włączonym trybem diagnostycznym; -- separacja danych kont; -- ochrona endpointów administracyjnych; -- limity uploadu; -- walidacja MIME; -- bezpieczne nazwy plików; -- izolacja temporary storage; -- czyszczenie danych po wylogowaniu / wygaśnięciu sesji. - -Każdy moduł powinien posiadać testy negatywne i regresyjne. - ---- - -# 19. Etap R — sesje i konto - -## 19.1. Sesje - -- lista aktywnych sesji; -- data utworzenia; -- ostatnia aktywność; -- urządzenie/przeglądarka w bezpiecznej formie; -- wyloguj tę sesję; -- wyloguj wszystkie pozostałe; -- autologout; -- remember me zgodnie z polityką bezpieczeństwa; -- automatyczne odświeżanie po reconnect. - -## 19.2. Zmiana hasła - -RustBox nie zakłada jednego mechanizmu backendowego. - -Administrator powinien móc skonfigurować adapter: - -- API; -- LDAP; -- PAM / command; -- SQL; -- custom executable; -- funkcja wyłączona. - -Hasło nie może być logowane ani zapisywane w plaintext. - ---- - -# 20. Etap S — diagnostyka - -Panel diagnostyczny użytkownika/administratora powinien pokazywać bez ujawniania danych poufnych: - -- IMAP status; -- SMTP status; -- ManageSieve status; -- CalDAV status; -- CardDAV status; -- IDLE status; -- ostatnie połączenie; -- ostatnia synchronizacja; -- liczba wiadomości w indeksie; -- kolejka zadań; -- status indeksowania; -- quota; -- ostatnie błędy; -- informacje o capabilities serwera; -- możliwość reconnect; -- możliwość rebuild index; -- health endpoint; -- readiness endpoint. - ---- - -# 21. Etap T — UI/UX docelowego RustBox - -## 21.1. Layout - -Desktop: - -```text -┌──────────────┬──────────────────────────┬───────────────────────────────┐ -│ Folder tree │ Message list │ Message preview │ -│ │ │ │ -│ Inbox │ Sender / Subject / Date │ Header │ -│ Sent │ Flags / Attachment │ Body │ -│ Drafts │ Threads │ Attachments │ -│ Archive │ │ │ -│ Spam │ │ │ -│ Trash │ │ │ -└──────────────┴──────────────────────────┴───────────────────────────────┘ -``` - -Dostępne ustawienia: - -- preview po prawej; -- preview na dole; -- preview wyłączony; -- zmiana szerokości paneli; -- compact / comfortable; -- mobile layout z osobnymi ekranami listy i wiadomości. - -## 21.2. Styl - -- brak gradientów; -- brak dekoracyjnych kolorowych kart; -- brak emoji jako ikon; -- popularne ikony SVG; -- małe i konsekwentne promienie; -- cienkie obramowania; -- spokojne kolory; -- jedna typografia UI; -- możliwość użycia `Inter` z fallbackiem systemowym bez zewnętrznego CDN; -- czytelne focus states; -- dostępność klawiaturą; -- odpowiedni kontrast; -- informacje nie mogą być przekazywane wyłącznie kolorem. - ---- - -# 22. Etap U — synchronizacja z Oxi - -RustBox pozostaje forkiem bazującym na Oxi i zachowuje możliwość śledzenia upstreamu. - -W repozytorium należy utrzymywać: - -- `UPSTREAM_BASE` z ostatnim w pełni przejrzanym i skonsumowanym commitem Oxi; -- remote `upstream-oxi`; -- skrypt setup upstream; -- skrypt status/diff; -- skrypt synchronizacji zmian backendowych; -- skrypt portowania pojedynczego commita; -- mechanizm finalizacji synchronizacji dopiero po przeglądzie zmian UI/root. - -Mapowanie: - -```text -Oxi backend/src/... → RustBox src/... -Oxi backend/Cargo.toml → RustBox Cargo.toml -Oxi backend/migrations/... → RustBox migrations/... -``` - -Frontend Oxi nie jest automatycznie importowany. - -Zmiany frontendowe Oxi powinny być raportowane jako potencjalne nowe funkcje i ręcznie portowane do interfejsu RustBox. - -Przy konfliktach Git nie wolno automatycznie nadpisywać lokalnych zmian RustBox. - ---- - -# 23. Etap V — testy akceptacyjne 0.0.1 - -## 23.1. Core mail - -- logowanie; -- wylogowanie; -- reconnect; -- folder tree; -- create folder; -- create subfolder; -- rename; -- delete; -- subscriptions; -- special folders; -- lista wiadomości; -- threading; -- podgląd; -- full headers; -- raw source; -- `.eml` download; -- reply; -- reply all; -- forward; -- forward as attachment; -- compose; -- drafts; -- attachments; -- inline images; -- signatures; -- identities; -- move; -- copy; -- delete; -- archive; -- spam; -- flags; -- unread; -- drag & drop; -- context menus. - -## 23.2. Sieve - -- list scripts; -- get; -- put; -- rename; -- delete; -- activate; -- deactivate; -- validation; -- visual builder; -- create filter from message; -- vacation; -- capability handling; -- TLS; -- STARTTLS; -- jawnie dozwolony tryb plaintext, jeśli administrator go włączy. - -## 23.3. Search - -- szybkie; -- zaawansowane; -- Tantivy; -- filtry; -- rebuild index; -- consistency po reconnect; -- UIDVALIDITY; -- delete/move/flags. - -## 23.4. Contacts / Calendar - -- CRUD kontaktów; -- zdjęcia; -- vCard; -- CardDAV; -- iCalendar; -- CalDAV; -- invitations; -- recurrence; -- timezone. - -## 23.5. PGP - -- generate/import/export; -- sign; -- verify; -- encrypt; -- decrypt; -- missing key; -- invalid signature; -- key protection. - -## 23.6. Security - -- XSS; -- CSRF; -- SSRF; -- malicious MIME; -- malformed headers; -- unsafe SVG; -- dangerous filenames; -- account isolation; -- session isolation; -- rate limiting; -- TLS verification; -- secrets not logged. - -## 23.7. Performance - -- skrzynka z dziesiątkami tysięcy wiadomości; -- duże wiadomości; -- duże attachmenty; -- długotrwałe IDLE; -- reconnect loop; -- indeksowanie; -- stabilność pamięci; -- graceful shutdown; -- restart i wznowienie synchronizacji. - ---- - -# 24. Kryterium zakończenia RustBox 0.0.1 - -`0.0.1` można uznać za gotową bazę dopiero wtedy, gdy: -0. WIelojezykowosc w stylu rustproxy -1. RustBox realizuje pełny podstawowy workflow webmaila bez ręcznego korzystania z innego klienta. -2. Foldery IMAP są w pełni zarządzalne z UI. -3. Wiadomości można czytać, filtrować, wyszukiwać, przenosić, kopiować, archiwizować i usuwać. -4. Composer obsługuje HTML, plain text, Markdown, attachmenty, obrazy inline, drafty, tożsamości i stopki. -5. Prawoklik i drag & drop działają jako pełnoprawne elementy UX. -6. Użytkownik ma dostęp do pełnych nagłówków i źródła RFC822. -7. ManageSieve posiada zarówno edytor tekstowy, jak i kreator wizualny oraz szybkie filtry z wiadomości. -8. Autoresponder działa przez Sieve. -9. Kontakty i autocomplete są kompletne. -10. OpenPGP pozwala podpisywać, szyfrować, weryfikować i odszyfrowywać pocztę. -11. IMAP capabilities są wykrywane i wykorzystywane bez udawania niewspieranych funkcji. -12. Shared folders, ACL i quota są obsługiwane tam, gdzie serwer je udostępnia. -13. Powiadomienia działają przez IDLE/WebSocket. -14. CardDAV/CalDAV i zaproszenia kalendarzowe są wdrożone. -15. Ustawienia użytkownika obejmują wygląd, zachowanie skrzynki, composer, identities, signatures, folders, filters, autoresponder, privacy, security i sessions. -16. UI jest spójne, bez gradientów i pseudo-ikon, ze standardowymi SVG. -17. Aplikacja nadal składa się z jednego procesu i jednej binarki produkcyjnej. -18. Runtime nie wymaga Node.js, Bun ani osobnego frontend servera. -19. Oxi pozostaje śledzonym upstreamem i można bezpiecznie portować jego nowe funkcje. -20. Testy funkcjonalne, bezpieczeństwa i stabilności przechodzą na rzeczywistych serwerach IMAP/SMTP/ManageSieve. - -Dopiero po spełnieniu tych warunków należy rozważyć oznaczenie kolejnej wersji jako pierwszej publicznej. diff --git a/docs/releases/v0.0.1.md b/docs/releases/v0.0.1.md deleted file mode 100644 index 2338e45..0000000 --- a/docs/releases/v0.0.1.md +++ /dev/null @@ -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/`. diff --git a/docs/releases/v0.0.2.md b/docs/releases/v0.0.2.md deleted file mode 100644 index 55dd612..0000000 --- a/docs/releases/v0.0.2.md +++ /dev/null @@ -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. diff --git a/docs/releases/v0.0.3.md b/docs/releases/v0.0.3.md deleted file mode 100644 index d663313..0000000 --- a/docs/releases/v0.0.3.md +++ /dev/null @@ -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. diff --git a/docs/releases/v0.0.4.md b/docs/releases/v0.0.4.md deleted file mode 100644 index afa6a4f..0000000 --- a/docs/releases/v0.0.4.md +++ /dev/null @@ -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. diff --git a/scripts/sync-upstream.sh b/scripts/sync-upstream.sh index 94015f7..7a58494 100755 --- a/scripts/sync-upstream.sh +++ b/scripts/sync-upstream.sh @@ -45,9 +45,6 @@ tmp_mapped="$(mktemp)" cleanup() { rm -f "$tmp_raw" "$tmp_mapped"; } 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/ to . File contents are not rewritten. git diff --binary --find-renames "$BASE..$TARGET_SHA" -- backend/ > "$tmp_raw" sed \ -e 's#^diff --git a/backend/#diff --git a/#' \ diff --git a/src/auth/csrf.rs b/src/auth/csrf.rs index d799fe9..b626fb3 100644 --- a/src/auth/csrf.rs +++ b/src/auth/csrf.rs @@ -3,24 +3,9 @@ use axum::http::{Method, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; -/// Methods that mutate state and therefore require CSRF protection. -const STATE_CHANGING_METHODS: [Method; 4] = [ - Method::POST, - Method::PUT, - Method::DELETE, - Method::PATCH, -]; +const STATE_CHANGING_METHODS: [Method; 4] = + [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 { if STATE_CHANGING_METHODS.contains(request.method()) && !request.headers().contains_key("x-requested-with") @@ -46,24 +31,19 @@ pub async fn csrf_protection(request: Request, next: Next) -> Response { #[cfg(test)] mod tests { use super::*; + use axum::Router; use axum::body::Body; use axum::routing::get; - use axum::Router; use http_body_util::BodyExt; use tower::ServiceExt; - /// Build a minimal router with CSRF middleware applied. fn app() -> Router { Router::new() .route("/test", get(|| async { "ok" }).post(|| async { "ok" })) .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"); if with_header { @@ -182,8 +162,7 @@ mod tests { .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); } } diff --git a/src/auth/imap_auth.rs b/src/auth/imap_auth.rs index 11ee44b..5a6a5b3 100644 --- a/src/auth/imap_auth.rs +++ b/src/auth/imap_auth.rs @@ -1,23 +1,14 @@ use async_imap::error::Error as ImapError; -/// Result of validating credentials against an IMAP server. #[derive(Debug, PartialEq)] pub enum AuthResult { - /// The credentials are valid; login succeeded (and we immediately logged out). Success, - /// The server rejected the credentials (wrong email or password). + InvalidCredentials, - /// Could not reach or communicate with the IMAP server. + 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( host: &str, port: u16, @@ -33,9 +24,13 @@ pub async fn validate_imap_credentials( } } -/// TLS path: connect via `tokio::net::TcpStream`, upgrade with -/// `async_native_tls::TlsConnector`, then wrap in `async_imap::Client`. -async fn validate_tls(host: &str, port: u16, tls_verify: bool, email: &str, password: &str) -> AuthResult { +async fn validate_tls( + host: &str, + port: u16, + tls_verify: bool, + email: &str, + password: &str, +) -> AuthResult { let addr = format!("{host}:{port}"); 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); - // Read the server greeting before attempting login. match client.read_response().await { Some(Ok(_)) => {} 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 } -/// 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 { 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); - // Read the server greeting before attempting login. match client.read_response().await { Some(Ok(_)) => {} 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 } -/// Shared login logic: attempt LOGIN, logout on success, classify errors. async fn attempt_login(client: async_imap::Client, email: &str, password: &str) -> AuthResult where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + std::fmt::Debug + Send, { match client.login(email, password).await { Ok(mut session) => { - // Successfully authenticated; clean up. let _ = session.logout().await; 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 { match err { ImapError::No(_) => AuthResult::InvalidCredentials, @@ -116,14 +100,11 @@ fn classify_login_error(err: ImapError) -> AuthResult { mod tests { 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] async fn unreachable_server_returns_server_unreachable() { 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!( matches!(result, AuthResult::ServerUnreachable(_)), @@ -131,11 +112,11 @@ mod tests { ); } - /// Same test but with TLS enabled: should also return `ServerUnreachable`. #[tokio::test] async fn unreachable_server_tls_returns_server_unreachable() { 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!( matches!(result, AuthResult::ServerUnreachable(_)), @@ -143,14 +124,12 @@ mod tests { ); } - /// Verify the error classifier maps `No` to `InvalidCredentials`. #[test] fn classify_no_as_invalid_credentials() { let err = ImapError::No("Authentication failed".into()); assert_eq!(classify_login_error(err), AuthResult::InvalidCredentials); } - /// Verify the error classifier maps I/O errors to `ServerUnreachable`. #[test] fn classify_io_as_server_unreachable() { let err = ImapError::Io(std::io::Error::new( @@ -164,7 +143,6 @@ mod tests { ); } - /// Verify the error classifier maps `Bad` responses to `ServerUnreachable`. #[test] fn classify_bad_as_server_unreachable() { let err = ImapError::Bad("server error".into()); diff --git a/src/auth/session.rs b/src/auth/session.rs index db6c209..aa997ee 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -37,13 +37,6 @@ pub struct AccountSession { 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)] pub struct SessionStore { sessions: Arc>, @@ -81,7 +74,10 @@ impl SessionStore { if let Some(value) = self.tls_verify_override { 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") } @@ -220,7 +216,6 @@ impl SessionStore { pub fn remove_account(&self, account_id: &str) -> bool { 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(|| { self.sessions.remove(&session_id); })) { @@ -229,7 +224,6 @@ impl SessionStore { 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) && let Some(mut entry) = self.browsers.get_mut(&browser_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) { let Some(session_id) = self .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 { let now = Instant::now(); let mut result: Vec = self @@ -280,7 +270,6 @@ impl SessionStore { 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 { let Some(session_id) = self .account_to_session @@ -299,7 +288,6 @@ impl SessionStore { 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 { let account_ids: Vec = self .sessions @@ -321,10 +309,6 @@ impl SessionStore { pub fn remove_browser(&self, browser_id: &str) { 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 { self.account_to_browser.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() } - /// 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) { self.account_to_session.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| { accounts.retain(|id| id != account_id); - // Keep the browser entry only if it still has accounts. + !accounts.is_empty() }); } @@ -408,7 +388,6 @@ impl SessionStore { pub fn purge_expired(&self) { let now = Instant::now(); - // Collect expired account IDs while retaining live sessions. let mut expired_accounts = Vec::new(); self.sessions.retain(|_, state| { 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)] pub fn check_consistency(&self) -> Vec { let mut issues = Vec::new(); - // Every session's account_id should have a matching account_to_session entry. for entry in self.sessions.iter() { let token = entry.key(); 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() { let account_id = entry.key(); 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() { let browser_id = entry.key(); 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() { let account_id = entry.key(); let browser_id = entry.value(); @@ -878,8 +850,6 @@ mod tests { #[test] 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 browser_id = store.create_browser(); @@ -911,8 +881,6 @@ mod tests { assert_eq!(session.imap_port, 993); } - // --- Consistency tests --- - fn add_test_account( store: &SessionStore, browser_id: &str, @@ -996,26 +964,23 @@ mod tests { ); assert_eq!(store.sessions.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)); } #[test] fn purge_expired_respects_timeout_override() { - let store = short_lived_store(); // 50ms default + let store = short_lived_store(); 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"); 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"); thread::sleep(Duration::from_millis(100)); store.purge_expired(); - // The long-timeout session should survive. assert!(store.get(&token).is_some()); assert!(store.account_to_session.contains_key(&account_id)); assert_eq!(store.sessions.len(), 1); @@ -1051,18 +1016,15 @@ mod tests { issues.is_empty(), "store should be consistent after mixed operations, got: {issues:?}" ); - // Only b@example.com should remain. + assert_eq!(store.sessions.len(), 1); assert_eq!(store.account_to_session.len(), 1); } - // --- Reverse index tests --- - #[test] fn remove_account_uses_reverse_index() { let store = long_lived_store(); - // Create many browsers to demonstrate the O(1) lookup let mut other_browsers = Vec::new(); for _ in 0..100 { let bid = store.create_browser(); @@ -1095,23 +1057,19 @@ mod tests { true, ); - // Verify reverse index was populated assert!(store.account_to_browser.contains_key(&account_id)); assert_eq!( store.account_to_browser.get(&account_id).unwrap().value(), &target_browser ); - // Remove the account -- uses O(1) reverse index, not O(n) iteration assert!(store.remove_account(&account_id)); - // Verify full cleanup assert!(store.get(&token).is_none()); assert!(!store.account_to_session.contains_key(&account_id)); assert!(!store.account_to_browser.contains_key(&account_id)); assert!(store.get_browser_accounts(&target_browser).is_empty()); - // Other browsers are untouched for bid in &other_browsers { assert_eq!(store.get_browser_accounts(bid).len(), 1); } @@ -1184,8 +1142,6 @@ mod tests { assert!(store.get_browser_accounts(&browser_id).is_empty()); } - // --- Cascade eviction tests --- - #[test] fn purge_expired_cascades_to_browsers_and_account_map() { let store = short_lived_store(); @@ -1224,7 +1180,6 @@ mod tests { let store = SessionStore::new(Duration::from_millis(150)); let browser_id = store.create_browser(); - // First account -- will expire. let (_token1, account1) = store.add_account_to_browser( &browser_id, "old@example.com".into(), @@ -1240,7 +1195,6 @@ mod tests { thread::sleep(Duration::from_millis(100)); - // Second account -- added later, still alive after sleep. let (_token2, account2) = store.add_account_to_browser( &browser_id, "new@example.com".into(), @@ -1297,7 +1251,6 @@ mod tests { thread::sleep(Duration::from_millis(100)); - // Accessing the expired session should trigger cascade cleanup. assert!(store.get(&token).is_none()); assert!( diff --git a/src/auth/user_data.rs b/src/auth/user_data.rs index 0f61768..a138030 100644 --- a/src/auth/user_data.rs +++ b/src/auth/user_data.rs @@ -8,29 +8,17 @@ mod embedded { 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 { let mut hasher = Sha256::new(); hasher.update(email.as_bytes()); let result = hasher.finalize(); - // Format each byte as two lowercase hex digits. + 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 { 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"); 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 Result, String> { let reader = BufReader::new(ics_text.as_bytes()); let parser = IcalParser::new(reader); @@ -72,13 +71,11 @@ pub fn parse_ics(ics_text: &str) -> Result, 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() { end_time = start_time.clone(); } if start_time.is_empty() { - // Skip events without a start time continue; } @@ -110,16 +107,11 @@ pub fn parse_ics(ics_text: &str) -> Result, String> { Ok(events) } -/// Check if a DTSTART/DTEND value is date-only (no time component). 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()) } -/// Parse an iCalendar date/datetime value to ISO 8601 format. fn parse_ical_datetime(value: &str, params: &Option)>>) -> String { - // Check for TZID parameter let _tzid = params.as_ref().and_then(|ps| { ps.iter() .find(|(k, _)| k == "TZID") @@ -130,12 +122,10 @@ fn parse_ical_datetime(value: &str, params: &Option)>>) let value = value.trim(); 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]); } if value.len() >= 15 && value.contains('T') { - // DateTime: YYYYMMDDTHHmmSS or YYYYMMDDTHHmmSSZ let date_part = &value[0..8]; let time_part = &value[9..]; @@ -157,11 +147,9 @@ fn parse_ical_datetime(value: &str, params: &Option)>>) return format!("{year}-{month}-{day}T{hour}:{min}:{sec}{suffix}"); } - // Fallback: return as-is 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 { if let Some(stripped) = value.strip_prefix("mailto:") { stripped.to_string() diff --git a/src/config.rs b/src/config.rs index 6ce4ca1..46daef8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,146 +7,102 @@ use figment::{ use ipnet::IpNet; 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)] #[allow(dead_code)] pub struct AppConfig { - /// Bind address for the HTTP server. #[serde(default = "default_host")] pub host: String, - /// Port for the HTTP server. #[serde(default = "default_port")] pub port: u16, - /// IMAP server hostname. Defaults to localhost when omitted. #[serde(default = "default_imap_host")] pub imap_host: Option, - /// IMAP server port. #[serde(default = "default_imap_port")] pub imap_port: u16, - /// SMTP server hostname. When unset or blank, IMAP host is used. #[serde(default)] pub smtp_host: Option, - /// SMTP server port. #[serde(default = "default_smtp_port")] pub smtp_port: u16, - /// Whether TLS is enabled for mail connections. #[serde(default = "default_tls_enabled")] 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)] pub tls_verify: Option, - /// Maximum size of one uploaded compose attachment, in MiB. #[serde(default = "default_max_attachment_size_mb")] 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")] pub max_message_size_mb: usize, - /// Directory for persistent data storage. #[serde(default = "default_data_dir")] pub data_dir: String, - /// Session timeout in hours. #[serde(default = "default_session_timeout_hours")] pub session_timeout_hours: u64, - /// Whether users may request a persistent "remember me" session. #[serde(default = "default_remember_me_enabled")] pub remember_me_enabled: bool, - /// Lifetime of a remembered session in days. #[serde(default = "default_remember_me_days")] pub remember_me_days: u64, - /// Environment the application is running in (development, production) #[serde(default = "default_environment")] 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")] 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")] pub default_interface_skin: String, - /// Optional base path prefix (e.g. "/mail") for serving behind a reverse proxy subpath. #[serde(default)] pub base_path: Option, - /// ManageSieve host. When unset, the authenticated account's IMAP host is used. #[serde(default)] pub sieve_host: Option, - /// ManageSieve TCP port. #[serde(default = "default_sieve_port")] pub sieve_port: u16, - /// ManageSieve transport: `starttls`, `implicit` or `none`. #[serde(default = "default_sieve_tls_mode")] pub sieve_tls_mode: String, - /// Explicit opt-in for sending reusable credentials over a cleartext Sieve connection. #[serde(default)] pub sieve_allow_insecure_auth: bool, - /// Network timeout for ManageSieve connect/read/write operations. #[serde(default = "default_sieve_timeout_secs")] 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)] pub trusted_proxies: Option, - /// Administrator-defined extra headers added to every outgoing message. - /// Each entry uses `Header-Name: value`. Structural/MIME headers are rejected. #[serde(default)] pub outgoing_headers: Vec, - /// Enable Web Key Directory lookups for OpenPGP recipient keys. #[serde(default = "default_pgp_wkd_enabled")] pub pgp_wkd_enabled: bool, - /// Allow optional HKP-over-HTTPS fallback for OpenPGP discovery. #[serde(default)] pub pgp_hkp_enabled: bool, - /// HKP-compatible HTTPS endpoint used when the fallback is enabled. #[serde(default = "default_pgp_hkp_url")] pub pgp_hkp_url: String, - /// Timeout for WKD/HKP network operations. #[serde(default = "default_pgp_network_timeout_secs")] pub pgp_network_timeout_secs: u64, - /// Maximum accepted OpenPGP key response size. #[serde(default = "default_pgp_max_key_bytes")] 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)] pub pgp_allow_private_export: bool, - /// Administrator defaults for special IMAP folders. Per-account settings override these. #[serde(default)] pub default_sent_folder: Option, #[serde(default)] @@ -302,7 +258,6 @@ impl Default for AppConfig { } impl AppConfig { - /// Use the root path when no reverse-proxy prefix is configured. pub fn normalized_base_path(&self) -> &str { self.base_path .as_deref() @@ -310,8 +265,6 @@ impl AppConfig { .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 { if let Some(value) = self.tls_verify { return value; @@ -331,8 +284,6 @@ impl AppConfig { 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 { let Some(ref proxies) = self.trusted_proxies else { return vec![]; @@ -409,10 +360,6 @@ impl AppConfig { 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)] pub fn load() -> Result { let path = std::env::var("RUSTBOX_CONFIG") @@ -423,8 +370,6 @@ impl AppConfig { 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() } @@ -515,8 +460,6 @@ mod tests { #[test] 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"); assert_eq!(config.host, "0.0.0.0"); @@ -555,8 +498,6 @@ mod tests { #[test] fn env_var_overrides_work() { - // Simulate env overrides via figment's tuple provider - // to verify all fields are overridable. let config: AppConfig = Figment::new() .merge(("host", "127.0.0.1")) .merge(("port", 8080u16)) @@ -611,10 +552,6 @@ mod tests { #[test] 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 { std::env::set_var("IMAP_HOST", "test-imap.example.com"); 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.data_dir, "/tmp/oxi-test"); - // Clean up unsafe { std::env::remove_var("IMAP_HOST"); std::env::remove_var("DATA_DIR"); diff --git a/src/db/calendar.rs b/src/db/calendar.rs index d8068d6..e093cc4 100644 --- a/src/db/calendar.rs +++ b/src/db/calendar.rs @@ -2,11 +2,6 @@ use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -// --------------------------------------------------------------------------- -// Structs -// --------------------------------------------------------------------------- - -/// A calendar event record, mirroring the `calendar_events` table. #[derive(Debug, Clone, Serialize)] pub struct CalendarEvent { pub id: String, @@ -31,7 +26,6 @@ pub struct CalendarEvent { pub updated_at: String, } -/// Fields for creating a new calendar event. #[derive(Debug, Deserialize)] pub struct CreateEvent { pub title: String, @@ -72,7 +66,6 @@ fn default_source() -> String { "manual".to_string() } -/// Fields for updating an existing calendar event. All optional. #[derive(Debug, Deserialize)] pub struct UpdateEvent { pub title: Option, @@ -90,7 +83,6 @@ pub struct UpdateEvent { pub reminder_minutes: Option, } -/// Calendar settings record, mirroring the `calendar_settings` table. #[derive(Debug, Clone, Serialize)] pub struct CalendarSettings { pub default_view: String, @@ -107,7 +99,6 @@ pub struct CalendarSettings { pub updated_at: String, } -/// Fields for updating calendar settings. All optional. #[derive(Debug, Deserialize)] pub struct UpdateCalendarSettings { pub default_view: Option, @@ -124,7 +115,6 @@ pub struct UpdateCalendarSettings { pub caldav_enabled: Option, } -/// A meeting template record. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeetingTemplate { pub id: i64, @@ -135,10 +125,6 @@ pub struct MeetingTemplate { pub created_at: String, } -// --------------------------------------------------------------------------- -// Row mappers -// --------------------------------------------------------------------------- - const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \ all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \ source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \ @@ -200,11 +186,6 @@ fn row_to_template(row: &rusqlite::Row<'_>) -> rusqlite::Result }) } -// --------------------------------------------------------------------------- -// Public API — Events -// --------------------------------------------------------------------------- - -/// List events in the given time range (inclusive start, inclusive end). pub fn list_events( conn: &Connection, start: &str, @@ -229,7 +210,6 @@ pub fn list_events( Ok(events) } -/// List all calendar events ordered by start time. pub fn list_all_events(conn: &Connection) -> Result, String> { let sql = format!( "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, String> Ok(events) } -/// Get a single event by ID. pub fn get_event(conn: &Connection, id: &str) -> Result, String> { let sql = format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1"); let result = conn.query_row(&sql, params![id], row_to_event); @@ -260,7 +239,6 @@ pub fn get_event(conn: &Connection, id: &str) -> Result, S } } -/// Create a new calendar event. Generates a UUID for the ID. pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result { let id = Uuid::new_v4().to_string(); let now: String = conn @@ -302,13 +280,11 @@ pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result Result, String> { - // Build SET clauses dynamically for non-None fields. let mut sets = Vec::new(); let mut values: Vec> = Vec::new(); let mut idx = 1u32; @@ -371,7 +347,6 @@ pub fn update_event( 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 { let deleted = conn .execute("DELETE FROM calendar_events WHERE id = ?1", params![id]) @@ -379,7 +354,6 @@ pub fn delete_event(conn: &Connection, id: &str) -> Result { Ok(deleted > 0) } -/// Find an event by its source_uid (for deduplication during ICS import). pub fn find_event_by_source_uid( conn: &Connection, 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, \ working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \ caldav_username, caldav_enabled, updated_at"; -/// Get calendar settings, inserting a default row if none exists. pub fn get_calendar_settings(conn: &Connection) -> Result { - // Ensure the singleton row exists. conn.execute( "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)", [], @@ -416,12 +384,10 @@ pub fn get_calendar_settings(conn: &Connection) -> Result Result { - // Ensure the singleton row exists. conn.execute( "INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)", [], @@ -483,11 +449,6 @@ pub fn update_calendar_settings( get_calendar_settings(conn) } -// --------------------------------------------------------------------------- -// Public API — Meeting Templates -// --------------------------------------------------------------------------- - -/// List all meeting templates. pub fn list_meeting_templates(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( @@ -506,7 +467,6 @@ pub fn list_meeting_templates(conn: &Connection) -> Result, Ok(templates) } -/// Create a new meeting template. pub fn create_meeting_template( conn: &Connection, name: &str, @@ -526,7 +486,6 @@ pub fn create_meeting_template( .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 { let deleted = conn .execute("DELETE FROM meeting_templates WHERE id = ?1", params![id]) @@ -534,10 +493,6 @@ pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result 0) } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; @@ -773,7 +728,6 @@ mod tests { assert_eq!(all.len(), 0); } - // Helper to create CreateEvent variants without full Clone impl impl CreateEvent { fn clone_fields(&self) -> Self { CreateEvent { diff --git a/src/db/contact_groups.rs b/src/db/contact_groups.rs index d8786eb..538328d 100644 --- a/src/db/contact_groups.rs +++ b/src/db/contact_groups.rs @@ -3,7 +3,6 @@ use serde::Serialize; use crate::db::contacts::Contact; -/// A contact group record, mirroring the `contact_groups` table. #[derive(Debug, Clone, Serialize)] pub struct ContactGroup { pub id: String, @@ -13,11 +12,6 @@ pub struct ContactGroup { pub updated_at: String, } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Create a new contact group. pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), String> { conn.execute( "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(()) } -/// List all contact groups with their member counts. pub fn list_groups(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( @@ -57,7 +50,6 @@ pub fn list_groups(conn: &Connection) -> Result, String> { Ok(groups) } -/// Update a group's name. pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result { let updated = conn .execute( @@ -68,7 +60,6 @@ pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result 0) } -/// Delete a contact group. CASCADE handles member rows. pub fn delete_group(conn: &Connection, id: &str) -> Result { let deleted = conn .execute("DELETE FROM contact_groups WHERE id = ?1", params![id]) @@ -76,7 +67,6 @@ pub fn delete_group(conn: &Connection, id: &str) -> Result { 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> { conn.execute( "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(()) } -/// Remove a contact from a group. pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result { let deleted = conn .execute( @@ -97,7 +86,6 @@ pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Res Ok(deleted > 0) } -/// List members (full Contact objects) for a group. pub fn list_group_members(conn: &Connection, group_id: &str) -> Result, String> { let mut stmt = conn .prepare( @@ -147,7 +135,6 @@ pub fn list_group_members(conn: &Connection, group_id: &str) -> Result) -> rusqlite::Result { Ok(Contact { id: row.get(0)?, @@ -49,7 +46,8 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result { birthday: row.get(9)?, emails: serde_json::from_str::>(&row.get::<_, String>(16)?).unwrap_or_default(), phones: serde_json::from_str::>(&row.get::<_, String>(17)?).unwrap_or_default(), - addresses: serde_json::from_str::>(&row.get::<_, String>(18)?).unwrap_or_default(), + addresses: serde_json::from_str::>(&row.get::<_, String>(18)?) + .unwrap_or_default(), is_favorite: row.get::<_, i32>(10)? != 0, last_contacted: row.get(11)?, contact_count: row.get(12)?, @@ -60,22 +58,14 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result { }) } -/// 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 { term.replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Insert or replace a contact. All fields are written. pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> { conn.execute( "INSERT INTO contacts @@ -114,7 +104,6 @@ pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String Ok(()) } -/// Get a contact by its primary key `id`. Returns `None` if not found. pub fn get_contact(conn: &Connection, id: &str) -> Result, String> { let sql = format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1"); let result = conn.query_row(&sql, params![id], row_to_contact); @@ -126,7 +115,6 @@ pub fn get_contact(conn: &Connection, id: &str) -> Result, Strin } } -/// Get a contact by email address. Returns `None` if not found. pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result, String> { let sql = format!( "SELECT {CONTACT_SELECT_COLS} FROM contacts \ @@ -142,8 +130,6 @@ pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result, @@ -160,8 +146,7 @@ pub fn list_contacts( ORDER BY name ASC 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); (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 { let deleted = conn .execute("DELETE FROM contacts WHERE id = ?1", params![id]) @@ -214,8 +198,6 @@ pub fn delete_contact(conn: &Connection, id: &str) -> Result { 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, String> { let escaped = escape_like(query); let pattern = format!("%{escaped}%"); @@ -241,9 +223,6 @@ pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result Result<(), String> { - // Insert from address. if !from_address.is_empty() { conn.execute( "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}"))?; } - // Insert to addresses from JSON array. insert_addresses_from_json(conn, to_json)?; - // Insert cc addresses from JSON array. insert_addresses_from_json(conn, cc_json)?; 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> { let entries: Vec = match serde_json::from_str(json) { Ok(v) => v, - Err(_) => return Ok(()), // Invalid JSON, skip silently. + Err(_) => return Ok(()), }; for entry in &entries { - let email = entry - .get("address") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let email = entry.get("address").and_then(|v| v.as_str()).unwrap_or(""); if email.is_empty() { continue; } @@ -341,8 +310,6 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin Ok(()) } -/// Increment the contact_count by 1 and set last_contacted to now for the -/// contact with the given email address. #[allow(dead_code)] pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> { conn.execute( @@ -357,9 +324,6 @@ pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), Str 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)] pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), 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(()) } -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( "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], @@ -380,7 +349,10 @@ pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str Ok(()) } -pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result)>, String> { +pub fn get_contact_photo( + conn: &Connection, + contact_id: &str, +) -> Result)>, String> { match conn.query_row( "SELECT content_type, data FROM contact_photos WHERE contact_id = ?1", params![contact_id], @@ -393,9 +365,12 @@ pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result Result { - conn.execute("DELETE FROM contact_photos WHERE contact_id = ?1", params![contact_id]) - .map(|n| n > 0) - .map_err(|e| format!("Failed to delete contact photo: {e}")) + conn.execute( + "DELETE FROM contact_photos WHERE contact_id = ?1", + params![contact_id], + ) + .map(|n| n > 0) + .map_err(|e| format!("Failed to delete contact photo: {e}")) } #[cfg(test)] @@ -403,7 +378,6 @@ mod tests { use super::*; 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 { Contact { id: id.to_string(), @@ -457,7 +431,6 @@ mod tests { assert_eq!(fetched.id, "c2"); assert_eq!(fetched.name, "Bob"); - // Non-existent email returns None. let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap(); assert!(missing.is_none()); } @@ -471,7 +444,6 @@ mod tests { let all = list_contacts(&conn, None, 100, 0).unwrap(); assert_eq!(all.len(), 2); - // Ordered by name ASC: Alice, Bob. assert_eq!(all[0].name, "Alice"); assert_eq!(all[1].name, "Bob"); } @@ -486,12 +458,10 @@ mod tests { .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(); assert_eq!(results.len(), 1); assert_eq!(results[0].name, "Alice Smith"); - // Search by email should find only Bob. let results = search_contacts(&conn, "bob@", 10).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].name, "Bob Jones"); @@ -505,11 +475,9 @@ mod tests { let deleted = delete_contact(&conn, "c1").unwrap(); assert!(deleted); - // Should be gone now. let fetched = get_contact(&conn, "c1").unwrap(); assert!(fetched.is_none()); - // Deleting again returns false. let deleted_again = delete_contact(&conn, "c1").unwrap(); assert!(!deleted_again); } @@ -526,12 +494,11 @@ mod tests { assert_eq!(fetched.source, "auto"); 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(); let fetched2 = get_contact_by_email(&conn, "auto@example.com") .unwrap() .unwrap(); - assert_eq!(fetched2.name, "Auto User"); // Name should NOT change. + assert_eq!(fetched2.name, "Auto User"); } #[test] @@ -539,7 +506,6 @@ mod tests { let conn = open_test_db(); 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(); @@ -554,7 +520,6 @@ mod tests { fn test_search_known_addresses_basic() { 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, @@ -588,14 +553,12 @@ mod tests { fn test_search_known_addresses_excludes_contacts() { let conn = open_test_db(); - // Add alice@test.com as a contact. upsert_contact( &conn, &sample_contact("c1", "alice@test.com", "Alice Contact"), ) .unwrap(); - // Populate known_addresses (simulating sync). populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap(); populate_known_addresses( &conn, @@ -614,10 +577,8 @@ mod tests { ) .unwrap(); - // Search for "test.com" should exclude alice@test.com (she's a contact). 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); let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect(); assert!(emails.contains(&"bob@test.com")); @@ -629,7 +590,6 @@ mod tests { fn test_search_known_addresses_handles_invalid_json() { let conn = open_test_db(); - // Populate known_addresses (simulating sync). populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap(); populate_known_addresses( &conn, @@ -663,7 +623,7 @@ mod tests { let conn = open_test_db(); 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(); 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() { 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", "[]") .unwrap(); diff --git a/src/db/drafts.rs b/src/db/drafts.rs index deafecd..65388dc 100644 --- a/src/db/drafts.rs +++ b/src/db/drafts.rs @@ -1,7 +1,6 @@ use rusqlite::{Connection, params}; use serde::Serialize; -/// A locally-saved draft message. #[derive(Debug, Clone, Serialize)] pub struct Draft { pub id: String, @@ -27,7 +26,6 @@ pub struct Draft { pub updated_at: String, } -/// An attachment associated with a draft. #[derive(Debug, Clone, Serialize)] pub struct DraftAttachment { pub id: String, @@ -39,8 +37,6 @@ pub struct DraftAttachment { 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> { if id.is_empty() || id.len() > 128 { 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(()) } -/// Insert or update a draft. Uses INSERT OR REPLACE to upsert. #[allow(clippy::too_many_arguments)] pub fn upsert_draft( conn: &Connection, @@ -119,7 +114,6 @@ pub fn upsert_draft( Ok(()) } -/// Retrieve a single draft by ID. pub fn get_draft(conn: &Connection, id: &str) -> Result, String> { let result = conn.query_row( "SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at @@ -159,7 +153,6 @@ pub fn get_draft(conn: &Connection, id: &str) -> Result, String> { } } -/// List all drafts, ordered by updated_at descending. pub fn list_drafts(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( @@ -203,8 +196,6 @@ pub fn list_drafts(conn: &Connection) -> Result, String> { Ok(drafts) } - -/// Update the IMAP location of the latest remote snapshot for a local draft. pub fn set_remote_location( conn: &Connection, id: &str, @@ -224,7 +215,6 @@ pub fn set_remote_location( Ok(()) } -/// Delete a draft by ID. Cascade will handle attachments in DB. pub fn delete_draft(conn: &Connection, id: &str) -> Result { let deleted = conn .execute("DELETE FROM drafts WHERE id = ?1", params![id]) @@ -232,7 +222,6 @@ pub fn delete_draft(conn: &Connection, id: &str) -> Result { Ok(deleted > 0) } -/// Add an attachment record for a draft. pub fn add_draft_attachment( conn: &Connection, id: &str, @@ -251,7 +240,6 @@ pub fn add_draft_attachment( Ok(()) } -/// Get all attachments for a draft. pub fn get_draft_attachments( conn: &Connection, draft_id: &str, @@ -284,7 +272,6 @@ pub fn get_draft_attachments( Ok(attachments) } -/// Delete a single attachment by ID. pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result { let deleted = conn .execute("DELETE FROM draft_attachments WHERE id = ?1", params![id]) @@ -336,8 +323,46 @@ mod tests { fn test_upsert_updates_existing() { 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(&conn, "draft-1", "carol@example.com", "", "", "v2", "body2", None, None, None, false, "normal", "html", "none", false, false, false).unwrap(); + upsert_draft( + &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(); assert_eq!(draft.to_addresses, "carol@example.com"); @@ -349,8 +374,16 @@ mod tests { fn test_list_drafts_returns_all() { let conn = open_test_db(); - upsert_draft(&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(); + upsert_draft( + &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(); assert_eq!(drafts.len(), 2); @@ -363,7 +396,11 @@ mod tests { fn test_delete_draft() { 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()); let deleted = delete_draft(&conn, "draft-1").unwrap(); @@ -374,9 +411,17 @@ mod tests { #[test] fn remote_location_survives_local_draft_update() { 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(); - 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(); assert_eq!(draft.remote_folder.as_deref(), Some("Drafts")); assert_eq!(draft.remote_uid, Some(42)); @@ -408,7 +453,11 @@ mod tests { fn test_add_and_get_attachments() { 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( &conn, @@ -443,8 +492,21 @@ mod tests { fn test_delete_attachment() { let conn = open_test_db(); - upsert_draft(&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(); + upsert_draft( + &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(); assert!(deleted); @@ -457,9 +519,31 @@ mod tests { fn test_cascade_delete_attachments_on_draft_delete() { let conn = open_test_db(); - upsert_draft(&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(); - add_draft_attachment(&conn, "att-2", "draft-1", "pic.jpg", "image/jpeg", 200, "/path2").unwrap(); + upsert_draft( + &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(); + add_draft_attachment( + &conn, + "att-2", + "draft-1", + "pic.jpg", + "image/jpeg", + 200, + "/path2", + ) + .unwrap(); delete_draft(&conn, "draft-1").unwrap(); diff --git a/src/db/folders.rs b/src/db/folders.rs index 91af031..0c4c255 100644 --- a/src/db/folders.rs +++ b/src/db/folders.rs @@ -1,7 +1,6 @@ use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; -/// A cached IMAP folder, mirroring the `folders` table schema. #[derive(Debug, Clone, Serialize)] pub struct CachedFolder { pub name: String, @@ -15,7 +14,6 @@ pub struct CachedFolder { pub highest_modseq: u64, } -/// Insert or replace a folder row in the `folders` table. #[allow(clippy::too_many_arguments)] pub fn upsert_folder( conn: &Connection, @@ -50,11 +48,6 @@ pub fn upsert_folder( 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( conn: &Connection, folder_name: &str, @@ -71,7 +64,6 @@ pub fn insert_folder_if_new( Ok(()) } -/// Return all cached folders, sorted alphabetically by name. pub fn get_all_folders(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( @@ -107,24 +99,15 @@ pub fn get_all_folders(conn: &Connection) -> Result, String> { Ok(folders) } -/// Delete folders whose names are NOT in the provided `current_names` list. -/// Returns the number of deleted rows. -pub fn remove_stale_folders( - conn: &Connection, - current_names: &[String], -) -> Result { +pub fn remove_stale_folders(conn: &Connection, current_names: &[String]) -> Result { if current_names.is_empty() { - // Delete all folders when the current list is empty. let deleted = conn .execute("DELETE FROM folders", []) .map_err(|e| format!("Failed to delete all folders: {e}"))?; return Ok(deleted); } - // Build a parameterized WHERE NOT IN clause. - let placeholders: Vec = (1..=current_names.len()) - .map(|i| format!("?{i}")) - .collect(); + let placeholders: Vec = (1..=current_names.len()).map(|i| format!("?{i}")).collect(); let sql = format!( "DELETE FROM folders WHERE name NOT IN ({})", placeholders.join(", ") @@ -142,18 +125,12 @@ pub fn remove_stale_folders( Ok(deleted) } -/// Touch `updated_at` on all folders so the folder-list cache TTL resets. pub fn touch_all_folders(conn: &Connection) -> Result<(), String> { - conn.execute( - "UPDATE folders SET updated_at = datetime('now')", - [], - ) - .map_err(|e| format!("Failed to touch folders: {e}"))?; + conn.execute("UPDATE folders SET updated_at = datetime('now')", []) + .map_err(|e| format!("Failed to touch folders: {e}"))?; 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( conn: &Connection, name: &str, @@ -169,9 +146,6 @@ pub fn update_folder_status( 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( conn: &Connection, name: &str, @@ -188,7 +162,6 @@ pub fn update_folder_sync_status( 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> { let unread: u32 = conn .query_row( @@ -207,8 +180,6 @@ pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), 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> { conn.execute( "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(()) } -/// 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 { let fresh: bool = conn .query_row( @@ -234,10 +203,11 @@ pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result Result { +pub fn is_folder_fresh( + conn: &Connection, + folder_name: &str, + max_age_secs: u32, +) -> Result { let fresh: bool = conn .query_row( "SELECT EXISTS( @@ -251,10 +221,10 @@ pub fn is_folder_fresh(conn: &Connection, folder_name: &str, max_age_secs: u32) Ok(fresh) } -/// Check whether a folder's messages cache has been invalidated -/// (i.e., `messages_updated_at IS NULL`), meaning its unread count was -/// manually adjusted and should not be recomputed from the messages table. -pub fn is_folder_messages_invalidated(conn: &Connection, folder_name: &str) -> Result { +pub fn is_folder_messages_invalidated( + conn: &Connection, + folder_name: &str, +) -> Result { let invalidated: bool = conn .query_row( "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) } -/// 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> { conn.execute( "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(()) } -/// Return a single folder by name, or `None` if not found. pub fn get_folder(conn: &Connection, name: &str) -> Result, String> { let result = conn.query_row( "SELECT name, delimiter, parent, flags, is_subscribed, @@ -308,18 +275,25 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result, } } -/// Delete a folder and all its cached messages. pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> { - conn.execute("DELETE FROM messages WHERE folder = ?1", params![folder_name]) - .map_err(|e| format!("Failed to delete folder messages: {e}"))?; + conn.execute( + "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]) .map_err(|e| format!("Failed to delete folder: {e}"))?; Ok(()) } -/// Rename a folder in the cache, updating both folders and messages tables. -pub fn rename_folder_in_cache(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> { - let tx = conn.unchecked_transaction().map_err(|e| format!("Failed to start folder rename transaction: {e}"))?; +pub fn rename_folder_in_cache( + conn: &Connection, + 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;") .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}")); } - tx.execute("UPDATE messages SET folder = ?1 WHERE folder = ?2", params![new_name, old_name]) - .map_err(|e| format!("Failed to rename messages folder: {e}"))?; - tx.execute("UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2", 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( + "UPDATE messages SET folder = ?1 WHERE folder = ?2", + params![new_name, old_name], + ) + .map_err(|e| format!("Failed to rename messages folder: {e}"))?; + tx.execute( + "UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2", + 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]) .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(()) } -/// Find a folder whose `flags` column contains the given attribute (e.g. `\Drafts`, `\Sent`). -/// Returns the folder name if found. -pub fn find_folder_by_attribute(conn: &Connection, attribute: &str) -> Result, String> { +pub fn find_folder_by_attribute( + conn: &Connection, + attribute: &str, +) -> Result, String> { conn.query_row( "SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1", [attribute], @@ -368,15 +356,36 @@ mod tests { fn test_upsert_and_get_folders() { let conn = open_test_db(); - upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 42, 5, 100, 200) - .unwrap(); - upsert_folder(&conn, "Sent", Some("/"), None, "\\Sent", true, 10, 0, 101, 300) - .unwrap(); + upsert_folder( + &conn, + "INBOX", + 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(); assert_eq!(folders.len(), 2); - // Sorted alphabetically: INBOX < Sent assert_eq!(folders[0].name, "INBOX"); assert_eq!(folders[0].total_count, 42); assert_eq!(folders[0].unread_count, 5); @@ -390,12 +399,33 @@ mod tests { fn test_upsert_updates_existing_folder() { let conn = open_test_db(); - upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 10, 2, 100, 50) - .unwrap(); + upsert_folder( + &conn, + "INBOX", + Some("/"), + None, + "\\HasNoChildren", + true, + 10, + 2, + 100, + 50, + ) + .unwrap(); - // Upsert again with different counts. - upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 99, 33, 100, 75) - .unwrap(); + upsert_folder( + &conn, + "INBOX", + Some("/"), + None, + "\\HasNoChildren", + true, + 99, + 33, + 100, + 75, + ) + .unwrap(); let folders = get_all_folders(&conn).unwrap(); assert_eq!(folders.len(), 1); @@ -416,7 +446,10 @@ mod tests { assert!(is_folder_messages_invalidated(&conn, "Sent").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(); assert_eq!(folder.uid_validity, 2); assert_eq!(folder.total_count, 1); @@ -430,15 +463,18 @@ mod tests { conn.execute( "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(); - let timestamp: String = conn.query_row( - "SELECT messages_updated_at FROM folders WHERE name = 'Sent'", - [], - |row| row.get(0), - ).unwrap(); + let timestamp: String = conn + .query_row( + "SELECT messages_updated_at FROM folders WHERE name = 'Sent'", + [], + |row| row.get(0), + ) + .unwrap(); assert_eq!(timestamp, "2000-01-01 00:00:00"); 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, "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 deleted = remove_stale_folders(&conn, ¤t).unwrap(); assert_eq!(deleted, 1); diff --git a/src/db/header_sync.rs b/src/db/header_sync.rs index 4d7cfc2..bd86321 100644 --- a/src/db/header_sync.rs +++ b/src/db/header_sync.rs @@ -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}; #[derive(Clone, Copy, PartialEq, Eq)] @@ -12,7 +6,6 @@ pub(crate) struct HeaderSync { } impl HeaderSync { - /// Capture before reading IMAP state, and retain for the whole sync pass. pub(crate) fn capture(conn: &Connection) -> Result { let generation = conn .query_row( @@ -28,7 +21,6 @@ impl HeaderSync { Ok(Self { generation }) } - /// Serialize the check and all header/index/status writes with removals. pub(crate) fn begin_write(self, conn: &Connection) -> Result, String> { let transaction = Transaction::new_unchecked(conn, TransactionBehavior::Immediate) .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> { conn.execute( "INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1') diff --git a/src/db/messages.rs b/src/db/messages.rs index bb92c83..285f323 100644 --- a/src/db/messages.rs +++ b/src/db/messages.rs @@ -1,9 +1,6 @@ use rusqlite::{Connection, params}; 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)] pub struct IndexSourceMessage { pub uid: u32, @@ -17,8 +14,6 @@ pub struct IndexSourceMessage { pub has_attachments: bool, } -/// A cached email message header, mirroring the query-visible columns of the -/// `messages` table. #[derive(Debug, Clone, Serialize)] pub struct CachedMessage { pub uid: u32, @@ -39,10 +34,6 @@ pub struct CachedMessage { pub reaction: Option, } -// --------------------------------------------------------------------------- -// Helper to map a row to CachedMessage (used in multiple queries) -// --------------------------------------------------------------------------- - fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result { let has_attachments_int: i32 = row.get(12)?; Ok(CachedMessage { @@ -65,17 +56,10 @@ fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result Result<(), String> { let transaction = 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()) } -/// Delete all messages in a folder (used when UIDVALIDITY changes). pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result { let deleted = conn .execute("DELETE FROM messages WHERE folder = ?1", params![folder]) @@ -147,7 +128,6 @@ pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result Result { conn.query_row( "SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1", @@ -157,9 +137,6 @@ pub fn max_uid(conn: &Connection, folder: &str) -> Result { .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, String> { let mut stmt = conn .prepare( @@ -193,27 +170,22 @@ pub fn list_index_source_messages(conn: &Connection) -> Result i64 { 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 { if date.is_empty() { return 0; } - // Try ISO 8601 format first (used in test fixtures and some IMAP servers). + if let Ok(epoch) = parse_iso8601_to_epoch(date) { return epoch; } - // Try mail-parser's RFC2822 date parsing (standard IMAP date format). + if let Some(dt) = mail_parser::DateTime::parse_rfc822(date) { 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 { return epoch; } @@ -221,9 +193,7 @@ fn parse_date_to_epoch(date: &str) -> i64 { 0 } -/// Convert a mail_parser::DateTime to a Unix epoch timestamp. 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 mut days: i64 = 0; for y in 1970..year { @@ -232,7 +202,7 @@ fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 { for y in (year..1970).rev() { 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 = (dt.month as usize).saturating_sub(1).min(11); 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; - let secs = days * 86400 + (dt.hour as i64) * 3600 + (dt.minute as i64) * 60 + (dt.second as i64); - // tz_before_gmt=true means the offset is negative (behind UTC) + let secs = + 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; if dt.tz_before_gmt { secs + tz_offset_secs @@ -257,9 +228,7 @@ fn is_leap_year(y: i64) -> bool { (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 { - // Simple parser for YYYY-MM-DDTHH:MM:SSZ let s = s.trim(); if s.len() < 19 { return Err(()); @@ -285,8 +254,6 @@ fn parse_iso8601_to_epoch(s: &str) -> Result { 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( conn: &Connection, folder: &str, @@ -319,10 +286,6 @@ pub fn get_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( conn: &Connection, folder: &str, @@ -339,7 +302,9 @@ pub fn get_messages_sorted( "date_desc" => "date_epoch DESC, uid DESC", "date_asc" => "date_epoch ASC, uid ASC", "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_desc" => "LOWER(subject) DESC, 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}")) } -/// Return the total number of cached messages for a folder. pub fn count_messages(conn: &Connection, folder: &str) -> Result { conn.query_row( "SELECT COUNT(*) FROM messages WHERE folder = ?1", @@ -380,9 +344,6 @@ pub fn count_messages(conn: &Connection, folder: &str) -> Result { .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 { let count: i64 = conn .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0)) @@ -390,7 +351,6 @@ pub fn count_all_messages(conn: &Connection) -> Result { 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( conn: &Connection, folder: &str, @@ -405,8 +365,6 @@ pub fn update_message_flags( 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)] pub fn cache_message_body( conn: &Connection, @@ -423,7 +381,15 @@ pub fn cache_message_body( SET body_html = ?1, body_text = ?2, body_cached = 1, attachments_json = ?3, raw_headers = ?4, email_theme = ?5 WHERE folder = ?6 AND uid = ?7", - 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}"))?; Ok(()) @@ -443,7 +409,6 @@ pub fn update_email_theme( Ok(()) } -/// Cached body data including attachment metadata and raw headers. pub struct CachedBody { pub html: Option, pub text: Option, @@ -452,7 +417,6 @@ pub struct CachedBody { pub email_theme: Option, } -/// Return the cached body if `body_cached = 1`, otherwise `None`. pub fn get_cached_body( conn: &Connection, 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(conn: &Connection, folder: &str, uid: u32) -> Result, String> { +pub fn get_single_message( + conn: &Connection, + folder: &str, + uid: u32, +) -> Result, String> { let sql = format!( "SELECT {MSG_SELECT_COLS} FROM messages WHERE folder = ?1 AND uid = ?2" ); - let result = conn.query_row(&sql, params![folder, uid], |row| { - row_to_cached_message(row) - }); + let result = conn.query_row(&sql, params![folder, uid], |row| row_to_cached_message(row)); match result { Ok(msg) => Ok(Some(msg)), @@ -505,7 +470,6 @@ pub fn get_single_message(conn: &Connection, folder: &str, uid: u32) -> Result Result<(), String> { conn.execute( "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(()) } -/// 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, String> { let mut stmt = conn .prepare( @@ -541,8 +502,10 @@ pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result Result, String> { +pub fn get_all_uids_and_flags( + conn: &Connection, + folder: &str, +) -> Result, String> { let mut stmt = conn .prepare("SELECT uid, flags FROM messages WHERE folder = ?1") .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 Result, String> { - // Escape % and _ characters in the message_id to prevent LIKE injection. let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_"); let like_pattern = format!("%{}%", escaped_target); let sql = format!( @@ -594,8 +550,7 @@ pub fn get_thread_messages( let mut messages = Vec::new(); for row in rows { 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 .references_header .as_deref() @@ -610,14 +565,6 @@ pub fn get_thread_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( conn: &Connection, message_id: &str, @@ -625,7 +572,6 @@ pub fn get_full_thread( ) -> Result, String> { use std::collections::HashSet; - // Step 1: Seed the set of known message IDs. let mut known_ids: HashSet = HashSet::new(); known_ids.insert(message_id.to_string()); 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 = HashSet::new(); let mut all_messages: Vec = Vec::new(); loop { - // Find IDs we haven't queried yet. - let to_query: Vec = known_ids - .difference(&processed_ids) - .cloned() - .collect(); + let to_query: Vec = known_ids.difference(&processed_ids).cloned().collect(); if to_query.is_empty() { break; @@ -656,7 +597,6 @@ pub fn get_full_thread( let found = get_thread_messages(conn, id)?; for msg in found { - // Extract IDs from each found message and add to known set. if let Some(ref mid) = msg.message_id && !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(); 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)); 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)] pub fn search_messages_sqlite( conn: &Connection, @@ -711,7 +646,6 @@ pub fn search_messages_sqlite( let mut param_values: Vec> = Vec::new(); 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()); if !text.is_empty() { @@ -741,7 +675,11 @@ pub fn search_messages_sqlite( } else { 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)); 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() { String::new() @@ -799,12 +737,14 @@ pub fn search_messages_sqlite( FROM messages {where_clause} ORDER BY date_epoch DESC - LIMIT ?{}", param_values.len() + 1 + LIMIT ?{}", + param_values.len() + 1 ); 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 .prepare(&sql) @@ -829,12 +769,10 @@ mod tests { use crate::db::folders::upsert_folder; 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) { 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) { upsert_message( conn, @@ -880,12 +818,10 @@ mod tests { assert!(e2 > e1, "date 2 ({e2}) should be after date 1 ({e1})"); 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"); assert!(e4 > 0, "epoch for rfc2822 date should be > 0, got {e4}"); assert_eq!(e1, e4, "ISO and RFC2822 should produce same epoch"); - // Empty returns 0. assert_eq!(parse_date_to_epoch(""), 0); } @@ -901,7 +837,6 @@ mod tests { let msgs = get_messages(&conn, "INBOX", 0, 10).unwrap(); assert_eq!(msgs.len(), 3); - // Should be date DESC: uid 3, 2, 1. assert_eq!(msgs[0].uid, 3); assert_eq!(msgs[1].uid, 2); assert_eq!(msgs[2].uid, 1); @@ -913,7 +848,12 @@ mod tests { ensure_folder(&conn, "INBOX"); 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(); @@ -924,7 +864,6 @@ mod tests { assert_eq!(page1.len(), 2); assert_eq!(page2.len(), 1); - // Verify no UIDs overlap between pages. let uids0: Vec = page0.iter().map(|m| m.uid).collect(); let uids1: Vec = page1.iter().map(|m| m.uid).collect(); let uids2: Vec = page2.iter().map(|m| m.uid).collect(); @@ -969,7 +908,6 @@ mod tests { ensure_folder(&conn, "INBOX"); 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(); assert!(body.is_none()); } @@ -1011,10 +949,16 @@ mod tests { assert!(body.is_none()); cache_message_body( - &conn, "INBOX", 1, - Some("

Test

"), Some("Test"), - None, None, Some(0), - ).unwrap(); + &conn, + "INBOX", + 1, + Some("

Test

"), + Some("Test"), + None, + None, + Some(0), + ) + .unwrap(); update_email_theme(&conn, "INBOX", 1, 1).unwrap(); @@ -1041,27 +985,51 @@ mod tests { let conn = open_test_db(); ensure_folder(&conn, "INBOX"); - // Original message. upsert_message( - &conn, "INBOX", 1, - Some(""), None, None, - "Hello", "alice@example.com", "Alice", "[]", "[]", - "2024-01-01T10:00:00Z", "", 100, false, "", None, - ).unwrap(); + &conn, + "INBOX", + 1, + Some(""), + 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( - &conn, "INBOX", 2, + &conn, + "INBOX", + 2, Some(""), Some(""), None, - "Re: Hello", "bob@example.com", "Bob", "[]", "[]", - "2024-01-02T10:00:00Z", "", 200, false, "", None, - ).unwrap(); + "Re: Hello", + "bob@example.com", + "Bob", + "[]", + "[]", + "2024-01-02T10:00:00Z", + "", + 200, + false, + "", + None, + ) + .unwrap(); let thread = get_thread_messages(&conn, "").unwrap(); assert_eq!(thread.len(), 2); - // ASC order: uid 1 first, uid 2 second. + assert_eq!(thread[0].uid, 1); assert_eq!(thread[1].uid, 2); } @@ -1071,28 +1039,52 @@ mod tests { let conn = open_test_db(); ensure_folder(&conn, "INBOX"); - // Original message. upsert_message( - &conn, "INBOX", 1, - Some(""), None, None, - "Hello", "alice@example.com", "Alice", "[]", "[]", - "2024-01-01T10:00:00Z", "", 100, false, "", None, - ).unwrap(); + &conn, + "INBOX", + 1, + Some(""), + 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( - &conn, "INBOX", 3, + &conn, + "INBOX", + 3, Some(""), Some(""), Some(" "), - "Re: Re: Hello", "carol@example.com", "Carol", "[]", "[]", - "2024-01-03T10:00:00Z", "", 300, false, "", None, - ).unwrap(); + "Re: Re: Hello", + "carol@example.com", + "Carol", + "[]", + "[]", + "2024-01-03T10:00:00Z", + "", + 300, + false, + "", + None, + ) + .unwrap(); let thread = get_thread_messages(&conn, "").unwrap(); assert_eq!(thread.len(), 2); - assert_eq!(thread[0].uid, 1); // matched by message_id - assert_eq!(thread[1].uid, 3); // matched by references_header LIKE + assert_eq!(thread[0].uid, 1); + assert_eq!(thread[1].uid, 3); } #[test] @@ -1101,17 +1093,45 @@ mod tests { ensure_folder(&conn, "INBOX"); upsert_message( - &conn, "INBOX", 1, - Some(""), None, None, - "Original", "alice@example.com", "Alice", "[]", "[]", - "2024-01-01T10:00:00Z", "", 100, false, "", None, - ).unwrap(); + &conn, + "INBOX", + 1, + Some(""), + None, + None, + "Original", + "alice@example.com", + "Alice", + "[]", + "[]", + "2024-01-01T10:00:00Z", + "", + 100, + false, + "", + None, + ) + .unwrap(); upsert_message( - &conn, "INBOX", 2, - Some(""), None, Some(""), - "Unrelated", "bob@example.com", "Bob", "[]", "[]", - "2024-01-02T10:00:00Z", "", 100, false, "", None, - ).unwrap(); + &conn, + "INBOX", + 2, + Some(""), + None, + Some(""), + "Unrelated", + "bob@example.com", + "Bob", + "[]", + "[]", + "2024-01-02T10:00:00Z", + "", + 100, + false, + "", + None, + ) + .unwrap(); let thread = get_thread_messages(&conn, "").unwrap(); assert_eq!(thread.len(), 1); @@ -1123,17 +1143,69 @@ mod tests { let conn = open_test_db(); ensure_folder(&conn, "INBOX"); - // Message 1: original - upsert_message(&conn, "INBOX", 1, Some(""), None, None, - "Hello", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap(); - // Message 2: reply to 1 - upsert_message(&conn, "INBOX", 2, Some(""), Some(""), Some(""), - "Re: Hello", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap(); - // Message 3: reply to 2, references both - upsert_message(&conn, "INBOX", 3, Some(""), Some(""), Some(" "), - "Re: Re: Hello", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap(); + upsert_message( + &conn, + "INBOX", + 1, + Some(""), + None, + None, + "Hello", + "alice@ex", + "Alice", + "[]", + "[]", + "2024-01-01T10:00:00Z", + "", + 100, + false, + "", + None, + ) + .unwrap(); + + upsert_message( + &conn, + "INBOX", + 2, + Some(""), + Some(""), + Some(""), + "Re: Hello", + "bob@ex", + "Bob", + "[]", + "[]", + "2024-01-02T10:00:00Z", + "", + 100, + false, + "", + None, + ) + .unwrap(); + + upsert_message( + &conn, + "INBOX", + 3, + Some(""), + Some(""), + Some(" "), + "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, "", Some("")).unwrap(); assert_eq!(thread.len(), 3); assert_eq!(thread[0].uid, 1); @@ -1146,14 +1218,67 @@ mod tests { let conn = open_test_db(); ensure_folder(&conn, "INBOX"); - upsert_message(&conn, "INBOX", 1, Some(""), None, None, - "Start", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap(); - upsert_message(&conn, "INBOX", 2, Some(""), Some(""), Some(""), - "Re: Start", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap(); - upsert_message(&conn, "INBOX", 3, Some(""), Some(""), Some(" "), - "Re: Re: Start", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap(); + upsert_message( + &conn, + "INBOX", + 1, + Some(""), + None, + None, + "Start", + "alice@ex", + "Alice", + "[]", + "[]", + "2024-01-01T10:00:00Z", + "", + 100, + false, + "", + None, + ) + .unwrap(); + upsert_message( + &conn, + "INBOX", + 2, + Some(""), + Some(""), + Some(""), + "Re: Start", + "bob@ex", + "Bob", + "[]", + "[]", + "2024-01-02T10:00:00Z", + "", + 100, + false, + "", + None, + ) + .unwrap(); + upsert_message( + &conn, + "INBOX", + 3, + Some(""), + Some(""), + Some(" "), + "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, "", Some(" ")).unwrap(); assert_eq!(thread.len(), 3); } @@ -1163,8 +1288,26 @@ mod tests { let conn = open_test_db(); ensure_folder(&conn, "INBOX"); - upsert_message(&conn, "INBOX", 1, Some(""), None, None, - "Solo", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap(); + upsert_message( + &conn, + "INBOX", + 1, + Some(""), + None, + None, + "Solo", + "alice@ex", + "Alice", + "[]", + "[]", + "2024-01-01T10:00:00Z", + "", + 100, + false, + "", + None, + ) + .unwrap(); let thread = get_full_thread(&conn, "", None).unwrap(); 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, String> { conn.query_row("SELECT SUM(size) FROM messages", [], |row| row.get(0)) .map_err(|e| e.to_string()) diff --git a/src/db/notification_preferences.rs b/src/db/notification_preferences.rs index 603e36d..c2a0547 100644 --- a/src/db/notification_preferences.rs +++ b/src/db/notification_preferences.rs @@ -22,8 +22,6 @@ pub struct UpdateNotificationPreferences { pub dnd_end: Option, } -/// Retrieve the singleton notification preferences row. -/// Returns sensible defaults if the row does not yet exist. pub fn get_preferences(conn: &Connection) -> Result { let result = conn.query_row( "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 { + Ok(( + enabled_int, + sound_int, + folders_json, + dnd_enabled_int, + dnd_start, + dnd_end, + updated_at, + )) => { let folders: Vec = serde_json::from_str(&folders_json) .map_err(|e| format!("Failed to parse folders JSON: {e}"))?; Ok(NotificationPreferences { @@ -67,13 +73,10 @@ pub fn get_preferences(conn: &Connection) -> Result Result { - // Ensure the row exists. conn.execute( "INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)", [], @@ -95,8 +98,8 @@ pub fn update_preferences( idx += 1; } if let Some(ref folders) = data.folders { - let folders_json = - serde_json::to_string(folders).map_err(|e| format!("Failed to serialize folders: {e}"))?; + let folders_json = serde_json::to_string(folders) + .map_err(|e| format!("Failed to serialize folders: {e}"))?; sets.push(format!("folders = ?{idx}")); values.push(Box::new(folders_json)); idx += 1; @@ -107,13 +110,17 @@ pub fn update_preferences( idx += 1; } 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}")); values.push(Box::new(dnd_start.clone())); idx += 1; } 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}")); values.push(Box::new(dnd_end.clone())); idx += 1; @@ -128,8 +135,7 @@ pub fn update_preferences( let sql = format!("UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}"); values.push(Box::new(1_i32)); - let params_refs: Vec<&dyn rusqlite::types::ToSql> = - values.iter().map(|v| v.as_ref()).collect(); + let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect(); conn.execute(&sql, params_refs.as_slice()) .map_err(|e| format!("Failed to update notification preferences: {e}"))?; @@ -138,7 +144,9 @@ pub fn update_preferences( } 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::(), minute.parse::()), (Ok(h), Ok(m)) if h < 24 && m < 60 && hour.len() == 2 && minute.len() == 2) } @@ -197,10 +205,7 @@ mod tests { .unwrap(); assert!(prefs.enabled); - assert_eq!( - prefs.folders, - vec!["INBOX".to_string(), "Sent".to_string()] - ); + assert_eq!(prefs.folders, vec!["INBOX".to_string(), "Sent".to_string()]); } #[test] diff --git a/src/db/pool.rs b/src/db/pool.rs index 083d66c..581b443 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -3,7 +3,6 @@ use std::path::Path; use rusqlite::Connection; -/// Owned database context for short blocking jobs. Never hold SQLite on a Tokio worker. #[derive(Clone)] pub(crate) struct UserDb { data_dir: String, @@ -32,7 +31,6 @@ impl UserDb { } } -/// All migration scripts in order. Each entry is (version, sql). const MIGRATIONS: &[(u32, &str)] = &[ (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> { let mut current: u32 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .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 = conn .query_row( "SELECT MAX(version) FROM refinery_schema_history", @@ -241,10 +230,6 @@ fn run_migrations(conn: &Connection) -> Result<(), String> { 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 { let dir = Path::new(data_dir).join(user_hash); 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 { 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() { let conn = open_test_db(); - // Verify the three expected tables exist. let tables: Vec = conn .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") .unwrap() @@ -312,11 +290,9 @@ mod tests { 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"); assert!(db_file.exists()); - // Foreign keys should be enabled. let fk: i32 = conn .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) .unwrap(); @@ -347,7 +323,7 @@ mod async_tests { .await }); ready.await.unwrap(); - // On a current-thread runtime, an inline DB job prevents this send. + release.send(()).unwrap(); job.await.unwrap().unwrap(); } diff --git a/src/db/special_folders.rs b/src/db/special_folders.rs index 1ddb697..c9a9cf5 100644 --- a/src/db/special_folders.rs +++ b/src/db/special_folders.rs @@ -1,4 +1,4 @@ -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; const ROLES: &[&str] = &["sent", "drafts", "trash", "junk", "archive"]; @@ -78,7 +78,11 @@ pub fn update_all(conn: &Connection, folders: &SpecialFolders) -> Result Result<(), String> { +pub fn rename_folder_reference( + conn: &Connection, + old_name: &str, + new_name: &str, +) -> Result<(), String> { conn.execute( "UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2", 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> { - conn.execute("DELETE FROM special_folders WHERE folder_name = ?1", [folder_name]) - .map_err(|e| format!("Failed to clear special folder reference: {e}"))?; + conn.execute( + "DELETE FROM special_folders WHERE folder_name = ?1", + [folder_name], + ) + .map_err(|e| format!("Failed to clear special folder reference: {e}"))?; Ok(()) } diff --git a/src/db/tags.rs b/src/db/tags.rs index 59091a0..7dbf5d1 100644 --- a/src/db/tags.rs +++ b/src/db/tags.rs @@ -1,11 +1,10 @@ use std::collections::HashMap; -use rusqlite::{params, Connection}; +use rusqlite::{Connection, params}; use serde::Serialize; use crate::db::messages::CachedMessage; -/// A tag record, mirroring the `tags` table. #[derive(Debug, Clone, Serialize)] pub struct Tag { pub id: String, @@ -17,7 +16,6 @@ pub struct Tag { pub updated_at: String, } -/// Lightweight tag info returned alongside messages. #[derive(Debug, Clone, Serialize)] pub struct MessageTag { pub id: String, @@ -25,13 +23,11 @@ pub struct MessageTag { pub color: String, } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Create a new tag. pub fn imap_keyword_for_tag_id(id: &str) -> String { - format!("RustBox-{}", id.chars().filter(|ch| *ch != '-').collect::()) + format!( + "RustBox-{}", + id.chars().filter(|ch| *ch != '-').collect::() + ) } 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(()) } -/// List all tags with their message counts. pub fn list_tags(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( @@ -81,22 +76,33 @@ pub fn get_tag(conn: &Connection, id: &str) -> Result, String> { Ok(tags.drain(..).find(|tag| tag.id == id)) } -pub fn get_message_refs_by_tag(conn: &Connection, tag_id: &str) -> Result, String> { - let mut stmt = conn.prepare("SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?1") +pub fn get_message_refs_by_tag( + conn: &Connection, + tag_id: &str, +) -> Result, 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}"))?; - 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}"))?; 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) } -/// Import RustBox-owned IMAP keyword flags into local message/tag relations. -/// This intentionally only adds remotely-present labels; absence is not treated as -/// deletion because some servers/folders do not expose persistent custom flags. -pub fn import_imap_keywords(conn: &Connection, uid: u32, folder: &str, flags: &[String]) -> Result { +pub fn import_imap_keywords( + conn: &Connection, + uid: u32, + folder: &str, + flags: &[String], +) -> Result { 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 mut imported = 0usize; for tag in tags { @@ -108,14 +114,6 @@ pub fn import_imap_keywords(conn: &Connection, uid: u32, folder: &str, flags: &[ 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( conn: &Connection, uid: u32, @@ -153,7 +151,6 @@ pub fn reconcile_imap_keywords( Ok((added, removed)) } -/// Update a tag's name and/or color. pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result { let updated = conn .execute( @@ -164,7 +161,6 @@ pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul Ok(updated > 0) } -/// Delete a tag. CASCADE handles junction rows. pub fn delete_tag(conn: &Connection, id: &str) -> Result { let deleted = conn .execute("DELETE FROM tags WHERE id = ?1", params![id]) @@ -172,7 +168,6 @@ pub fn delete_tag(conn: &Connection, id: &str) -> Result { Ok(deleted > 0) } -/// Add a tag to a message (INSERT OR IGNORE to avoid duplicates). pub fn add_tag_to_message( conn: &Connection, tag_id: &str, @@ -187,7 +182,6 @@ pub fn add_tag_to_message( Ok(()) } -/// Remove a tag from a message. pub fn remove_tag_from_message( conn: &Connection, tag_id: &str, @@ -203,8 +197,11 @@ pub fn remove_tag_from_message( Ok(deleted > 0) } -/// Get all tags for a specific message. -pub fn get_message_tags(conn: &Connection, uid: u32, folder: &str) -> Result, String> { +pub fn get_message_tags( + conn: &Connection, + uid: u32, + folder: &str, +) -> Result, String> { let mut stmt = conn .prepare( "SELECT t.id, t.name, t.color @@ -232,7 +229,6 @@ pub fn get_message_tags(conn: &Connection, uid: u32, folder: &str) -> Result = messages .iter() .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(); let sql = format!( @@ -267,7 +267,8 @@ pub fn get_tags_for_messages( param_values.push(Box::new(uid)); 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 .query_map(params_ref.as_slice(), |row| { @@ -290,7 +291,6 @@ pub fn get_tags_for_messages( Ok(map) } -/// Get messages by tag (paginated), ordered by date descending. pub fn get_messages_by_tag( conn: &Connection, tag_id: &str, @@ -342,7 +342,6 @@ pub fn get_messages_by_tag( Ok(messages) } -/// Count messages with a given tag. pub fn count_messages_by_tag(conn: &Connection, tag_id: &str) -> Result { conn.query_row( "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, "t2", 1, "INBOX").unwrap(); - // Duplicate should be ignored + add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap(); let tags = get_message_tags(&conn, 1, "INBOX").unwrap(); assert_eq!(tags.len(), 2); 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] @@ -483,13 +489,9 @@ mod tests { add_tag_to_message(&conn, "t1", 7, "INBOX").unwrap(); let keyword_two = imap_keyword_for_tag_id("t2"); - let (added, removed) = reconcile_imap_keywords( - &conn, - 7, - "INBOX", - &["\\Seen".to_string(), keyword_two], - ) - .unwrap(); + let (added, removed) = + reconcile_imap_keywords(&conn, 7, "INBOX", &["\\Seen".to_string(), keyword_two]) + .unwrap(); assert_eq!(added, 1); assert_eq!(removed, 1); @@ -497,5 +499,4 @@ mod tests { assert_eq!(tags.len(), 1); assert_eq!(tags[0].id, "t2"); } - } diff --git a/src/email_theme.rs b/src/email_theme.rs index ab1aa7a..b27b7cd 100644 --- a/src/email_theme.rs +++ b/src/email_theme.rs @@ -53,8 +53,6 @@ static TD_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"# static DIV_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).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> = LazyLock::new(|| { NAMED_COLORS .iter() @@ -65,7 +63,6 @@ static NAMED_COLOR_REGEXES: LazyLock> = LazyLock::new .collect() }); -/// Named CSS colors mapped to RGB values. type NamedColorList = Vec<(&'static str, (u8, u8, u8))>; static NAMED_COLORS: LazyLock = LazyLock::new(|| { 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 { if html.trim().is_empty() { return None; @@ -148,9 +136,6 @@ pub fn detect_email_theme(html: &str) -> Option { 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) { return Some(EmailTheme::Adaptive); } @@ -164,8 +149,6 @@ pub fn detect_email_theme(html: &str) -> Option { 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 { let lower = html.to_lowercase(); for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) { @@ -177,8 +160,6 @@ fn has_dark_mode_media_query(html: &str) -> bool { 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 { let mut light_weight: f32 = 0.0; let mut dark_weight: f32 = 0.0; @@ -205,20 +186,12 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option= 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) { return Some(EmailTheme::Transparent); } Some(EmailTheme::Light) } 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 { Some(EmailTheme::Transparent) } else { @@ -229,19 +202,15 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option bool { if colors.is_empty() { return false; } for bg in colors { - // If there's a body-level background, it's intentional if bg.element_weight >= 1.0 { return false; } - // Only consider pure white as a wrapper reset + let c = bg.color.trim().to_lowercase(); if c != "#ffffff" && c != "white" { return false; @@ -273,21 +242,16 @@ fn extract_background_colors(html: &str) -> Vec { extract_style_tag_colors(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); 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) { if colors.len() <= 1 { return; } - // Track seen (normalized_color, weight) pairs and remove exact duplicates let mut seen: HashSet = HashSet::new(); colors.retain(|bg| { let key = format!("{}:{:.1}", bg.color, bg.element_weight); @@ -334,7 +298,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec) { continue; } - // Extract from body{...} rules if let Some(body_cap) = BODY_CSS_RE.captures(style_content) { let body_styles = &body_cap[1]; if body_styles.to_lowercase().contains("background") { @@ -342,7 +305,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec) { } } - // Extract from class rules that mention background for class_cap in CLASS_CSS_RE.captures_iter(style_content) { let class_styles = &class_cap[1]; if class_styles.to_lowercase().contains("background") { @@ -350,7 +312,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec) { } } - // Extract from element-level rules (td{...}, table{...}, etc.) for elem_cap in ELEM_CSS_RE.captures_iter(style_content) { let tag = elem_cap[1].to_lowercase(); let elem_styles = &elem_cap[2]; @@ -368,12 +329,9 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec) { } } -/// 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) { 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) { let bg_value = bg_cap[1].trim(); @@ -425,8 +383,6 @@ fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec) { } } -/// A color extracted from an inline style, with its alpha value. struct ExtractedColor { color: String, alpha: f32, @@ -478,8 +433,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec { .map(|m| m.as_str().to_lowercase()) .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) { let bg_value = bg_cap[1].trim(); @@ -512,8 +465,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec { }); } - // 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() { if re.is_match(bg_value) { colors.push(ExtractedColor { @@ -529,8 +480,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec { 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 { let c = color.trim().to_lowercase(); if let Some(hex) = c.strip_prefix('#') { @@ -545,7 +494,6 @@ fn parse_hex_alpha(color: &str) -> f32 { 1.0 } -/// Parse an alpha string that may be a decimal (`0.5`) or percentage (`50%`). fn parse_alpha_str(s: &str) -> f32 { if let Some(pct) = s.strip_suffix('%') { pct.parse::().unwrap_or(100.0) / 100.0 @@ -577,7 +525,6 @@ fn normalize_color(color: &str) -> String { } 6 => c, 8 => { - // 8-digit hex: strip alpha channel format!("#{}", &hex[0..6]) } _ => c, @@ -595,7 +542,6 @@ fn calculate_luminance(color: &str) -> f64 { fn parse_color(color: &str) -> (u8, u8, u8) { let c = color.trim().to_lowercase(); - // Check named colors first for (name, rgb) in NAMED_COLORS.iter() { if c == *name { 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) } @@ -686,7 +631,6 @@ mod tests { #[test] fn test_luminance_grouping_mixed_light_colors() { - // Multiple light colors should still classify as Light let html = r#" @@ -700,29 +644,24 @@ mod tests { #[test] 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##"
Click here
"##; assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); } #[test] fn test_deduplicate_bgcolor_and_style() { - // Same color from both bgcolor and style on same td should be deduplicated let html = r##"
Click
"##; assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); } #[test] 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#"
Content
"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); } #[test] fn test_named_color_white() { - // Body with white background is intentional let html = r#"Content"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); } @@ -760,8 +699,6 @@ mod tests { #[test] 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#"Content"#; let colors = extract_background_colors(html); assert_eq!(colors.len(), 1); @@ -810,7 +747,6 @@ mod tests { #[test] fn test_named_color_in_style_tag_exact_match() { - // Ensure style-tag extraction also uses exact matching let html = r#"Content"#; let colors = extract_background_colors(html); assert!( @@ -826,8 +762,6 @@ mod tests { #[test] 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 = r#"Content"#; let html_rgb = r#"Content"#; @@ -851,7 +785,6 @@ mod tests { #[test] fn test_rgba_fully_transparent_ignored() { - // rgba(0,0,0,0) should be skipped entirely (alpha < 0.1) let html = r#"Content"#; let colors = extract_background_colors(html); assert!( @@ -863,7 +796,6 @@ mod tests { #[test] 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 = r#"Content"#; let html_rgb = @@ -885,7 +817,6 @@ mod tests { #[test] fn test_rgba_in_style_tag() { - // RGBA in a Content"#; let colors = extract_background_colors(html); assert_eq!(colors.len(), 1); diff --git a/src/error.rs b/src/error.rs index 700c186..37bfb14 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,13 +2,11 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Serialize; -/// Structured JSON error envelope returned to clients. #[derive(Debug, Serialize)] struct ErrorEnvelope { error: ErrorBody, } -/// Body of the error envelope. #[derive(Debug, Serialize)] struct ErrorBody { code: String, @@ -16,29 +14,25 @@ struct ErrorBody { status: u16, } -/// Application-level error type that converts into an Axum response -/// with a structured JSON error envelope. #[derive(Debug)] #[allow(dead_code)] pub enum AppError { - /// Internal server error (500). InternalError(String), - /// Resource not found (404). + NotFound(String), - /// Unauthorized access (401). + Unauthorized(String), - /// Bad request (400). + BadRequest(String), - /// Authenticated but forbidden by policy (403). + Forbidden(String), - /// Service unavailable (503). + ServiceUnavailable(String), - /// Request payload is larger than the configured mail limit (413). + PayloadTooLarge(String), } impl AppError { - /// Returns the HTTP status code for this error variant. fn status_code(&self) -> StatusCode { match self { 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 { match self { AppError::InternalError(_) => "INTERNAL_ERROR", @@ -64,7 +57,6 @@ impl AppError { } } - /// Returns the human-readable message for this error variant. fn message(&self) -> &str { match self { AppError::InternalError(msg) diff --git a/src/imap/client.rs b/src/imap/client.rs index 961e37f..731d86a 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -19,38 +19,24 @@ pub(crate) use super::connection::connect; #[path = "mock.rs"] 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(clippy::double_must_use)] #[async_trait] pub trait ImapClient: Send + Sync { - /// Return the capabilities advertised by the authenticated IMAP server. async fn capabilities(&self, creds: &ImapCredentials) -> Result, 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> { Ok(()) } - /// List all folders (mailboxes) on the server. async fn list_folders(&self, creds: &ImapCredentials) -> Result, ImapError>; - /// Perform a lightweight `SELECT` on a folder to get its status - /// (UIDVALIDITY, EXISTS count, UIDNEXT) without fetching any messages. async fn folder_status( &self, creds: &ImapCredentials, folder: &str, ) -> Result; - /// Fetch message headers (envelopes) for a range of UIDs in a folder. async fn fetch_headers( &self, creds: &ImapCredentials, @@ -58,7 +44,6 @@ pub trait ImapClient: Send + Sync { uid_range: &str, ) -> Result, ImapError>; - /// Fetch the full body of a single message by UID. async fn fetch_body( &self, creds: &ImapCredentials, @@ -66,7 +51,6 @@ pub trait ImapClient: Send + Sync { uid: u32, ) -> Result; - /// Fetch the complete RFC822 message bytes for source/download operations. async fn fetch_raw_message( &self, _creds: &ImapCredentials, @@ -79,7 +63,6 @@ pub trait ImapClient: Send + Sync { }) } - /// Add flags to a message (IMAP +FLAGS). async fn add_flags( &self, creds: &ImapCredentials, @@ -88,7 +71,6 @@ pub trait ImapClient: Send + Sync { flags: &[&str], ) -> Result<(), ImapError>; - /// Remove flags from a message (IMAP -FLAGS). async fn remove_flags( &self, creds: &ImapCredentials, @@ -97,7 +79,6 @@ pub trait ImapClient: Send + Sync { flags: &[&str], ) -> Result<(), ImapError>; - /// Set (replace) the flags on a message. async fn set_flags( &self, creds: &ImapCredentials, @@ -106,7 +87,6 @@ pub trait ImapClient: Send + Sync { flags: &[&str], ) -> Result<(), ImapError>; - /// Move a message from one folder to another. async fn move_message( &self, creds: &ImapCredentials, @@ -115,7 +95,6 @@ pub trait ImapClient: Send + Sync { to_folder: &str, ) -> Result<(), ImapError>; - /// Copy a message to another folder without removing the source. async fn copy_message( &self, creds: &ImapCredentials, @@ -124,13 +103,10 @@ pub trait ImapClient: Send + Sync { to_folder: &str, ) -> Result<(), ImapError>; - /// Mark all messages in a folder as read. 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>; - /// Permanently remove a message that has the `\Deleted` flag. async fn expunge_message( &self, creds: &ImapCredentials, @@ -138,7 +114,6 @@ pub trait ImapClient: Send + Sync { uid: u32, ) -> Result<(), ImapError>; - /// Append a raw RFC822 message to a folder (e.g. saving sent mail to "Sent"). async fn append_message( &self, creds: &ImapCredentials, @@ -147,11 +122,6 @@ pub trait ImapClient: Send + Sync { flags: &[&str], ) -> 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( &self, creds: &ImapCredentials, @@ -162,14 +132,12 @@ pub trait ImapClient: Send + Sync { identity_value: &str, ) -> Result<(u32, u32), ImapError>; - /// Create a new folder (mailbox) and subscribe to it. async fn create_folder( &self, creds: &ImapCredentials, folder_name: &str, ) -> Result<(), ImapError>; - /// Rename an existing folder. async fn rename_folder( &self, creds: &ImapCredentials, @@ -177,14 +145,12 @@ pub trait ImapClient: Send + Sync { to: &str, ) -> Result<(), ImapError>; - /// Permanently delete a folder (mailbox). async fn delete_folder( &self, creds: &ImapCredentials, folder_name: &str, ) -> Result<(), ImapError>; - /// Subscribe to or unsubscribe from a folder. async fn subscribe_folder( &self, creds: &ImapCredentials, @@ -192,24 +158,18 @@ pub trait ImapClient: Send + Sync { subscribe: bool, ) -> 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( &self, creds: &ImapCredentials, folder: &str, ) -> Result)>, ImapError>; - /// Lightweight STATUS command to get folder metadata without SELECT. - /// Returns UIDVALIDITY, EXISTS, UIDNEXT, UNSEEN, and HIGHESTMODSEQ. async fn folder_status_extended( &self, creds: &ImapCredentials, folder: &str, ) -> Result; - /// Fetch only messages whose flags changed since `since_modseq` using CONDSTORE. - /// Returns (vec of (uid, flags), new_highest_modseq). async fn fetch_changed_flags( &self, creds: &ImapCredentials, @@ -217,19 +177,14 @@ pub trait ImapClient: Send + Sync { since_modseq: u64, ) -> Result<(Vec<(u32, Vec)>, 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, ImapError>; - /// Fetch the total size of all messages in a folder via UID FETCH 1:* (RFC822.SIZE). async fn fetch_folder_size( &self, creds: &ImapCredentials, folder: &str, ) -> Result; - /// 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( &self, _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)] pub struct RealImapClient { pool: super::pool::ConnectionPool, } -// ---- Trait implementation ------------------------------------------------- - #[async_trait] impl ImapClient for RealImapClient { async fn capabilities(&self, creds: &ImapCredentials) -> Result, ImapError> { @@ -363,9 +308,6 @@ impl ImapClient for RealImapClient { })?; 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 .uid_fetch( uid_range, @@ -391,7 +333,6 @@ impl ImapClient for RealImapClient { None => continue, }; - // Parse threading headers from the small HEADER.FIELDS response. let raw_header_bytes = fetch.header(); let parsed_threading = raw_header_bytes .and_then(|raw| mail_parser::MessageParser::default().parse(raw)); @@ -429,9 +370,6 @@ impl ImapClient for RealImapClient { (subject, from, to, cc, date) } 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!( uid = uid, folder = %folder, @@ -440,7 +378,6 @@ impl ImapClient for RealImapClient { (None, vec![], vec![], vec![], None) }; - // Extract threading headers from the small HEADER.FIELDS response. let message_id = parsed_threading .as_ref() .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}>"))) }); - // Detect Outlook/Exchange reaction emails from headers. let reaction = raw_header_bytes.and_then(|raw| { let header_str = std::str::from_utf8(raw).ok()?; let lower = header_str.to_lowercase(); @@ -587,7 +523,6 @@ impl ImapClient for RealImapClient { let mut attachments = Vec::new(); - // Collect explicit attachments. for attachment in parsed.attachments() { let filename: Option = attachment.attachment_name().map(|s| s.to_string()); 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() { if part.content_id().is_none() { 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"); if is_text { continue; @@ -630,7 +563,7 @@ impl ImapClient for RealImapClient { .unwrap() .trim_matches(|c| c == '<' || c == '>') .to_string(); - // Skip if we already captured this part via attachments(). + if attachments .iter() .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_headers = raw_str .split_once("\r\n\r\n") @@ -780,7 +712,6 @@ impl ImapClient for RealImapClient { .await .map_err(map_imap_error)?; - // Consume the stream to completion so the command finishes. while let Some(result) = store_stream.next().await { result.map_err(map_imap_error)?; } @@ -803,12 +734,9 @@ impl ImapClient for RealImapClient { 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 { Ok(()) => {} Err(async_imap::error::Error::No(_) | async_imap::error::Error::Bad(_)) => { - // Fallback: COPY, then flag \Deleted, then EXPUNGE. session .uid_copy(&uid_str, to_folder) .await @@ -907,7 +835,6 @@ impl ImapClient for RealImapClient { let uid_str = uid.to_string(); - // Mark the message as \Deleted. { let mut store_stream = session .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 = { match session.uid_expunge(&uid_str).await { Ok(stream) => { @@ -1214,8 +1140,7 @@ impl ImapClient for RealImapClient { .run_command("GETQUOTAROOT INBOX") .await .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 mut quota = None; while let Some(response) = session.read_response().await { @@ -1277,8 +1202,6 @@ impl ImapClient for RealImapClient { 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 { use async_imap::imap_proto::{Response, Status, types::AttributeValue}; let req_id = session diff --git a/src/imap/connection.rs b/src/imap/connection.rs index f9ad693..9e5ca78 100644 --- a/src/imap/connection.rs +++ b/src/imap/connection.rs @@ -1,7 +1,6 @@ use super::error::ImapError; use super::types::ImapCredentials; -// Counts connection attempts by credential identity for isolated regression tests. #[cfg(test)] static CONNECTION_COUNTS: std::sync::LazyLock< std::sync::Mutex>, @@ -16,14 +15,6 @@ pub(super) fn connection_count(creds: &ImapCredentials) -> usize { .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( creds: &ImapCredentials, ) -> Result, ImapError> { @@ -36,7 +27,7 @@ pub(crate) async fn connect( .or_default() += 1; } 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) .await .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( session: &mut async_imap::Session, ) -> 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 { match err { 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 { match err { 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)] pub(crate) enum ImapStream { Tls(async_native_tls::TlsStream), diff --git a/src/imap/error.rs b/src/imap/error.rs index 1363be6..f0a9340 100644 --- a/src/imap/error.rs +++ b/src/imap/error.rs @@ -1,17 +1,15 @@ use std::fmt; -/// Errors that can occur during IMAP operations. #[derive(Debug)] pub enum ImapError { - /// Could not connect to the IMAP server. ConnectionFailed(String), - /// The server rejected our credentials. + AuthenticationFailed, - /// The requested folder does not exist. + FolderNotFound(String), - /// The requested message UID was not found in the given folder. + MessageNotFound { uid: u32, folder: String }, - /// A low-level IMAP protocol error. + ProtocolError(String), } diff --git a/src/imap/mock.rs b/src/imap/mock.rs index 2ac95b4..b6bf36a 100644 --- a/src/imap/mock.rs +++ b/src/imap/mock.rs @@ -1,11 +1,6 @@ use super::*; 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 { folders: Mutex>, headers: Mutex>, @@ -15,12 +10,11 @@ pub struct MockImapClient { should_fail: Mutex>, pub appended: Mutex)>>, pub header_requests: Mutex>, - /// One-shot gate after taking a header snapshot, before returning it. + pub header_fetch_gate: Option<(tokio::sync::Notify, tokio::sync::Notify)>, } impl MockImapClient { - /// Create a new empty mock. pub fn new() -> Self { Self { 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) -> Self { *self.folders.lock().unwrap() = folders; self } - /// Pre-load message headers that `fetch_headers` will return. pub fn with_headers(self, headers: Vec) -> Self { *self.headers.lock().unwrap() = headers; self } - /// Pre-load a folder status that `folder_status` will return. #[allow(dead_code)] pub fn with_folder_status(self, status: FolderStatus) -> Self { *self.folder_status.lock().unwrap() = Some(status); self } - /// Pre-load an extended folder status that `folder_status_extended` will return. #[allow(dead_code)] pub fn with_folder_status_extended(self, status: FolderStatusExtended) -> Self { *self.folder_status_ext.lock().unwrap() = Some(status); self } - /// Pre-load message bodies that `fetch_body` will match against by UID. pub fn with_bodies(self, bodies: Vec) -> Self { *self.bodies.lock().unwrap() = bodies; self } - /// Make every subsequent call return this error. pub fn with_error(self, error: ImapError) -> Self { *self.should_fail.lock().unwrap() = Some(error); 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 { match err { ImapError::ConnectionFailed(msg) => ImapError::ConnectionFailed(msg.clone()), @@ -127,7 +113,7 @@ impl ImapClient for MockImapClient { return Ok(s.clone()); } } - // Derive from headers (separate lock scope). + let headers = self.headers.lock().unwrap(); let exists = headers.len() as u32; let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1; @@ -255,11 +241,7 @@ impl ImapClient for MockImapClient { Ok(()) } - async fn empty_folder( - &self, - _creds: &ImapCredentials, - _folder: &str, - ) -> Result<(), ImapError> { + async fn empty_folder(&self, _creds: &ImapCredentials, _folder: &str) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } @@ -390,7 +372,7 @@ impl ImapClient for MockImapClient { return Ok(s.clone()); } } - // Derive from headers. + let headers = self.headers.lock().unwrap(); let exists = headers.len() as u32; 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() { return Err(clone_error(err)); } - // In mock, return all headers as "changed" with modseq 0. + let headers = self.headers.lock().unwrap(); let items: Vec<(u32, Vec)> = headers.iter().map(|h| (h.uid, h.flags.clone())).collect(); @@ -438,15 +420,10 @@ impl ImapClient for MockImapClient { } } -// ----------------------------------------------------------------------- -// Tests -// ----------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; - /// Convenience helper to build dummy credentials for tests. fn test_creds() -> ImapCredentials { ImapCredentials { host: "imap.example.com".to_string(), @@ -636,7 +613,7 @@ mod tests { let creds = test_creds(); let err = client.list_folders(&creds).await.unwrap_err(); - // With a fake host the connection should fail. + assert!( matches!(err, ImapError::ConnectionFailed(_)), "Expected ConnectionFailed, got: {err}" @@ -760,19 +737,6 @@ mod tests { 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 { let host = std::env::var("TEST_IMAP_HOST").ok()?; let port: u16 = std::env::var("TEST_IMAP_PORT").ok()?.parse().ok()?; @@ -793,7 +757,7 @@ mod tests { } #[tokio::test] - #[ignore] // Run manually: cargo test real_imap_list_folders -- --ignored + #[ignore] async fn real_imap_list_folders() { let creds = real_creds().expect("TEST_IMAP_* env vars required"); let client = RealImapClient::default(); @@ -807,24 +771,23 @@ mod tests { } #[tokio::test] - #[ignore] // Run manually: cargo test real_imap_fetch_headers -- --ignored + #[ignore] async fn real_imap_fetch_headers() { let creds = real_creds().expect("TEST_IMAP_* env vars required"); let client = RealImapClient::default(); 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 { assert!(h.uid > 0); } } #[tokio::test] - #[ignore] // Run manually: cargo test real_imap_fetch_body -- --ignored + #[ignore] async fn real_imap_fetch_body() { let creds = real_creds().expect("TEST_IMAP_* env vars required"); let client = RealImapClient::default(); - // First fetch headers to find a UID. let headers = client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap(); if let Some(h) = headers.first() { let body = client.fetch_body(&creds, "INBOX", h.uid).await.unwrap(); diff --git a/src/imap/parse.rs b/src/imap/parse.rs index 9f661ce..7ffa684 100644 --- a/src/imap/parse.rs +++ b/src/imap/parse.rs @@ -1,7 +1,5 @@ use super::types::EmailAddress; -// ---- Helper: convert NameAttribute to string ------------------------------ - pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'_>) -> String { use async_imap::types::NameAttribute; 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 { use async_imap::types::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 { - // Quick check: if there's no encoded-word marker, return as-is. if !input.contains("=?") { return input.to_string(); } @@ -50,11 +42,10 @@ pub(crate) fn decode_rfc2047(input: &str) -> String { let mut remaining = input; while let Some(start) = remaining.find("=?") { - // Add any text before the encoded word. result.push_str(&remaining[..start]); let after_start = &remaining[start + 2..]; - // Find charset?encoding?text?= pattern. + if let Some(q1) = after_start.find('?') { let charset = &after_start[..q1]; 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); result.push_str(&text); } else { - // Failed to decode — keep the original encoded word. let full_len = 2 + q1 + 1 + q2 + 1 + end + 2; result.push_str(&remaining[start..start + full_len]); } - // Skip past the encoded word. let skip = q1 + 1 + q2 + 1 + end + 2; remaining = &after_start[skip..]; - // Strip whitespace between consecutive encoded words (RFC 2047 §6.2). + if remaining.starts_with("=?") || remaining.trim_start().starts_with("=?") { 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("=?"); remaining = after_start; } @@ -103,7 +91,6 @@ pub(crate) fn decode_rfc2047(input: &str) -> String { result } -/// Decode quoted-printable as used in RFC 2047 headers (underscores = spaces). pub(crate) fn decode_quoted_printable_header(input: &str) -> Option> { let mut bytes = Vec::with_capacity(input.len()); let mut chars = input.bytes(); @@ -124,20 +111,19 @@ pub(crate) fn decode_quoted_printable_header(input: &str) -> Option> { 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 { match charset.to_ascii_lowercase().as_str() { "utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(), "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()) .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<'_>) -> EmailAddress { +pub(crate) fn imap_address_to_email( + addr: &async_imap::imap_proto::types::Address<'_>, +) -> EmailAddress { let name = addr .name .as_ref() @@ -164,24 +150,20 @@ pub(crate) fn imap_address_to_email(addr: &async_imap::imap_proto::types::Addres EmailAddress { name, address } } -// ---- Helper: detect attachments from BODYSTRUCTURE ----------------------- - pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<'_>) -> bool { use async_imap::imap_proto::types::BodyStructure; match bs { BodyStructure::Basic { common, .. } => { - // Check for explicit "attachment" disposition. if let Some(ref disp) = common.disposition && disp.ty.eq_ignore_ascii_case("attachment") { return true; } - // Non-text basic parts (image, application, audio, video) are likely attachments. + let ty = common.ty.ty.to_ascii_lowercase(); matches!(ty.as_str(), "application" | "image" | "audio" | "video") } BodyStructure::Text { common, .. } => { - // Text parts with "attachment" disposition count. if let Some(ref disp) = common.disposition { return disp.ty.eq_ignore_ascii_case("attachment"); } diff --git a/src/imap/pool.rs b/src/imap/pool.rs index 52e41bc..aea7e00 100644 --- a/src/imap/pool.rs +++ b/src/imap/pool.rs @@ -1,4 +1,3 @@ -//! Request-path sessions only; IDLE owns a separate connection. use super::{ connection::{ImapStream, connect}, error::ImapError, @@ -19,7 +18,6 @@ use tokio::{ time::Instant, }; -/// Mark IMAP operations in this future as background work. pub(crate) async fn background(work: impl std::future::Future) -> T { BACKGROUND.scope((), work).await } @@ -47,7 +45,6 @@ struct UserPool { idle: Mutex>, } -// Length-prefix fields to avoid ambiguous identities. Never retain plaintext passwords. pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] { let mut hash = Sha256::new(); for field in [ @@ -64,8 +61,6 @@ pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] { 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> { use async_imap::imap_proto::{Response, Status}; let id = session @@ -98,7 +93,7 @@ impl PoolInner { fn evict(&self) { self.users.lock().unwrap().retain(|_, user| { 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.is_empty() || Arc::strong_count(user) > 1 }); @@ -106,8 +101,6 @@ impl PoolInner { } 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) { self.inner.users.lock().unwrap().remove(&identity(creds)); } @@ -139,7 +132,7 @@ impl ConnectionPool { }) }) .clone(); - // Wait outside the shared budget so queued sync work cannot starve reads. + let background_permit = if BACKGROUND.try_with(|_| ()).is_ok() { Some( user.background_slots @@ -189,7 +182,7 @@ impl ConnectionPool { action = "connect", "IMAP pool checkout" ); - // Bound TCP, TLS and LOGIN together, retaining connect's TCP timeout too. + let session = tokio::time::timeout(CHECK_TIMEOUT, connect(creds)) .await .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 { session: Option, user: Arc, @@ -299,7 +290,6 @@ mod tests { break; } if line.contains(" UID FETCH ") && selected == "stalled" { - // No completion. The caller must discard on cancellation/timeout. continue; } let response = if line.contains(" GETQUOTAROOT ") { @@ -402,7 +392,7 @@ mod tests { .is_err() ); 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(); assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1); lease.recycle(); @@ -478,7 +468,7 @@ mod tests { for _ in 0..5 { client.list_folders(&creds).await.unwrap(); 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, "Archive").await.unwrap(), diff --git a/src/imap/types.rs b/src/imap/types.rs index 0803428..3c5cc54 100644 --- a/src/imap/types.rs +++ b/src/imap/types.rs @@ -1,88 +1,76 @@ use serde::{Deserialize, Serialize}; -/// Represents an IMAP folder (mailbox). #[derive(Debug, Clone, Serialize)] pub struct ImapFolder { - /// Folder name as returned by the IMAP server (e.g. "INBOX", "Sent"). pub name: String, - /// Delimiter used by the server (e.g. "/" or "."). + pub delimiter: Option, - /// IMAP attributes for this folder (e.g. `\Noselect`, `\HasChildren`). + pub attributes: Vec, } -/// A parsed email address with optional display name. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EmailAddress { - /// Display name, if present (e.g. "Alice Smith"). pub name: Option, - /// The actual email address (e.g. "alice@example.com"). + pub address: String, } -/// A lightweight summary of an email message (envelope data). #[derive(Debug, Clone, Serialize)] pub struct ImapMessageHeader { - /// IMAP UID of the message within its folder. pub uid: u32, - /// Subject line. + pub subject: Option, - /// Sender(s) of the message. + pub from: Vec, - /// Recipient(s) of the message. + pub to: Vec, - /// Date header value (raw string from the server). + pub date: Option, - /// IMAP flags currently set on this message (e.g. `\Seen`, `\Flagged`). + pub flags: Vec, - /// Whether this message has attachments (derived from BODYSTRUCTURE). + pub has_attachments: bool, - /// RFC 2822 size of the message in bytes. + pub size: u32, - /// Message-ID header value for threading. + pub message_id: Option, - /// In-Reply-To header value for threading. + pub in_reply_to: Option, - /// References header value for threading. + pub references: Option, - /// CC addresses. + pub cc: Vec, - /// Reaction emoji if this is an Outlook/Exchange reaction notification. + pub reaction: Option, } -/// The full body of an email message, including attachments. #[derive(Debug, Clone, Serialize)] pub struct ImapMessageBody { - /// IMAP UID of the message within its folder. pub uid: u32, - /// Plain-text body part, if available. + pub text_plain: Option, - /// HTML body part, if available. + pub text_html: Option, - /// List of attachments found in the message. + pub attachments: Vec, - /// Raw RFC 822 headers as a single string. + pub raw_headers: String, } -/// Metadata about a single attachment in an email message. #[derive(Debug, Clone, Serialize)] pub struct ImapAttachment { - /// Filename of the attachment, if provided by the sender. pub filename: Option, - /// MIME content type (e.g. "application/pdf"). + pub content_type: String, - /// Size in bytes. + pub size: usize, - /// Raw attachment content. + pub data: Vec, - /// Content-ID for inline images (e.g. "image001@01D1234"), without angle brackets. + pub content_id: Option, } -/// Parameters needed to establish an IMAP connection. -/// Passed explicitly to every trait method so the trait stays stateless. #[derive(Debug, Clone)] pub struct ImapCredentials { pub host: String, @@ -93,28 +81,22 @@ pub struct ImapCredentials { pub password: String, } -/// Lightweight result of an IMAP `SELECT` command. #[derive(Debug, Clone, Serialize)] pub struct FolderStatus { - /// UIDVALIDITY - changes when the mailbox is rebuilt or UIDs are reassigned. pub uid_validity: u32, - /// The total number of messages currently in the folder. + pub exists: u32, - /// The highest UID that will be assigned to the next appended message. + pub uid_next: u32, } -/// Mailbox quota information from IMAP GETQUOTAROOT. #[derive(Debug, Clone, Serialize)] pub struct MailboxQuota { - /// Storage used in bytes (STORAGE resource reports in KB, we convert). pub usage_bytes: u64, - /// Storage limit in bytes. + 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)] pub struct FolderStatusExtended { pub uid_validity: u32, diff --git a/src/main.rs b/src/main.rs index 35d3080..35c7083 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,14 +32,11 @@ async fn main() -> Result<(), Box> { return Ok(()); } - // Initialize structured JSON logging with env filter. - // Default to INFO level; override with RUST_LOG env var. tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) .with(fmt::layer().json()) .init(); - // Load configuration via figment (serde defaults + env vars). let loaded_config = AppConfig::load()?; loaded_config.validate().map_err(|message| { std::io::Error::new( @@ -53,13 +50,11 @@ async fn main() -> Result<(), Box> { } 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( Duration::from_secs(config.session_timeout_hours * 3600), config.tls_verify, )); - // Spawn a background task that periodically purges expired sessions. { let store = Arc::clone(&store); tokio::spawn(async move { @@ -72,20 +67,16 @@ async fn main() -> Result<(), Box> { }); } - // Create the IMAP and SMTP clients for production use. let imap_client: Arc = Arc::new(RealImapClient::default()); let smtp_client: Arc = 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( &config.data_dir, ))); - // Create the real-time event bus and IDLE manager. let event_bus = Arc::new(realtime::events::EventBus::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( config.clone(), store, @@ -96,7 +87,6 @@ async fn main() -> Result<(), Box> { idle_manager.clone(), ); - // Bind to the configured host and port. let bind_addr = format!("{}:{}", config.host, config.port); let listener = tokio::net::TcpListener::bind(&bind_addr).await?; diff --git a/src/pgp/discovery.rs b/src/pgp/discovery.rs index 00e4433..46792da 100644 --- a/src/pgp/discovery.rs +++ b/src/pgp/discovery.rs @@ -126,7 +126,7 @@ async fn https_get_address( format!("{host}:{port}") }; let request = format!( - "GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.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())) .await diff --git a/src/pgp/mod.rs b/src/pgp/mod.rs index e13c02e..d7143a8 100644 --- a/src/pgp/mod.rs +++ b/src/pgp/mod.rs @@ -78,8 +78,6 @@ pub fn generate_key(name: &str, email: &str, passphrase: &str) -> Result Result { - // 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"); if armored { 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> { - // 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( b"RustBox OpenPGP unlock self-test", 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}"))?; 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() || !fingerprint .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( data_dir: &str, user_hash: &str, @@ -680,10 +669,6 @@ pub fn looks_like_openpgp(raw: &[u8]) -> bool { || 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( raw: &[u8], secret_armors: &[String], diff --git a/src/realtime/events.rs b/src/realtime/events.rs index 850ac18..7b7fd9e 100644 --- a/src/realtime/events.rs +++ b/src/realtime/events.rs @@ -3,29 +3,25 @@ use std::collections::HashMap; use serde::Serialize; use tokio::sync::{RwLock, broadcast}; -/// Events that can be pushed to connected WebSocket clients. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", content = "data")] #[allow(dead_code)] pub enum MailEvent { - /// New messages arrived in a folder. NewMessages { folder: String, count: u32, + latest_uid: Option, latest_sender: Option, latest_subject: Option, }, - /// Flags changed on messages in a folder. - FlagsChanged { folder: String }, - /// Folder list or counts changed. + + FlagsChanged { + folder: String, + }, + 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 { channels: RwLock>>, } @@ -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 { - // Fast path: read lock. { let channels = self.channels.read().await; 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 tx = channels.entry(user_hash.to_string()).or_insert_with(|| { let (tx, _) = broadcast::channel(64); @@ -57,22 +49,16 @@ impl EventBus { 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) { let channels = self.channels.read().await; if let Some(tx) = channels.get(user_hash) { - // Ignore error (no receivers connected). 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) { let mut channels = self.channels.write().await; if let Some(tx) = channels.get(user_hash) { - // Only remove if no active receivers remain. if tx.receiver_count() == 0 { channels.remove(user_hash); } @@ -101,6 +87,7 @@ mod tests { MailEvent::NewMessages { folder: "INBOX".to_string(), count: 1, + latest_uid: Some(42), latest_sender: Some("alice@example.com".to_string()), latest_subject: Some("Hello".to_string()), }, @@ -120,7 +107,7 @@ mod tests { #[tokio::test] async fn publish_to_nonexistent_user_is_noop() { let bus = EventBus::new(); - // Should not panic. + bus.publish("ghost", MailEvent::FolderUpdated).await; } diff --git a/src/realtime/idle.rs b/src/realtime/idle.rs index 5ed931d..28dde18 100644 --- a/src/realtime/idle.rs +++ b/src/realtime/idle.rs @@ -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::{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 { 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( &self, 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) { use dashmap::mapref::entry::Entry; @@ -70,7 +66,6 @@ impl IdleManager { } } - /// Abort all dedicated IDLE tasks during process shutdown. pub fn stop_all(&self) { for task in self.tasks.iter() { 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( user_hash: &str, folder: &str, @@ -112,7 +101,6 @@ async fn idle_loop( loop { match run_idle_session(user_hash, folder, creds, event_bus, config).await { Ok(()) => { - // Session ended normally (shouldn't happen in practice). tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session ended normally"); 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( user_hash: &str, folder: &str, @@ -156,31 +139,22 @@ async fn run_idle_session( 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); loop { - // `.idle()` consumes the session, wrapping it in a Handle. let mut idle_handle = session.idle(); - // Initialize the IDLE command with the server. idle_handle .init() .await .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(); - // Wait for the server to send an unsolicited response, or timeout. let result = tokio::time::timeout(idle_timeout, idle_wait).await; - // Drop the interrupt handle now that we're done waiting. drop(interrupt); - // Send DONE to end IDLE and get the session back. session = idle_handle .done() .await @@ -197,14 +171,10 @@ async fn run_idle_session( "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; (session, result) = fetch_new_after_idle(session, user_hash, folder, config).await; - // Publish the correct event based on what changed. if result.new_count > 0 { event_bus .publish( @@ -212,6 +182,7 @@ async fn run_idle_session( MailEvent::NewMessages { folder: folder.to_string(), count: result.new_count, + latest_uid: result.latest_uid, latest_sender: result.latest_sender, latest_subject: result.latest_subject, }, @@ -245,11 +216,9 @@ async fn run_idle_session( } } Ok(Err(e)) => { - // IDLE protocol error. return Err(format!("IDLE error: {e}")); } Err(_) => { - // Our timeout — re-enter IDLE to keep the connection alive. tracing::debug!( user_hash = %user_hash, folder = %folder, @@ -260,11 +229,11 @@ async fn run_idle_session( } } -/// Summary of what changed during a post-IDLE reconciliation. struct ReconcileResult { new_count: u32, flags_updated: u32, deleted_count: u32, + latest_uid: Option, latest_sender: Option, latest_subject: Option, } @@ -275,26 +244,19 @@ impl ReconcileResult { new_count: 0, flags_updated: 0, deleted_count: 0, + latest_uid: None, latest_sender: 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( mut session: async_imap::Session, user_hash: &str, folder: &str, config: &AppConfig, ) -> (async_imap::Session, ReconcileResult) { - // Read cached folder state from DB. let (cached_modseq, max_cached_uid, cached_count, cached_uid_validity, header_sync) = match db::pool::open_user_db(&config.data_dir, user_hash) { 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 { Ok(mb) => mb, Err(e) => { @@ -331,7 +292,6 @@ async fn fetch_new_after_idle( let server_modseq = mailbox.highest_modseq.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 let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) { let _ = db::folders::invalidate_folder_freshness(&conn, folder); @@ -339,7 +299,6 @@ async fn fetch_new_after_idle( 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 { tracing::debug!( user_hash = %user_hash, @@ -350,7 +309,6 @@ async fn fetch_new_after_idle( return (session, ReconcileResult::empty()); } - // ── CONDSTORE fast path ────────────────────────────────────────── if cached_modseq > 0 && server_modseq > 0 { let fetch_query = format!("(UID FLAGS) (CHANGEDSINCE {})", cached_modseq); let changed_items: Option)>> = @@ -388,7 +346,6 @@ async fn fetch_new_after_idle( }; if let Some(changed) = changed_items { - // Separate flag changes (uid ≤ max) from new messages (uid > max). let mut flag_changes: Vec<(u32, Vec)> = Vec::new(); let mut has_new = false; 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; if !flag_changes.is_empty() && 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; if has_new { let uid_range = format!("{}:*", max_cached_uid + 1); @@ -440,21 +395,18 @@ async fn fetch_new_after_idle( .await; } - // Detect deletions if server count < cached count. let mut deleted_count = 0u32; if server_exists < cached_count { deleted_count = 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_sender, latest_subject) = if new_count > 0 { + let (latest_uid, latest_sender, latest_subject) = if new_count > 0 { lookup_latest_message(config, user_hash, folder) } 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) && let Ok(transaction) = header_sync.begin_write(&conn) { @@ -486,15 +438,14 @@ async fn fetch_new_after_idle( new_count, flags_updated, deleted_count, + latest_uid, latest_sender, latest_subject, }, ); } - // CHANGEDSINCE fetch failed — fall through to full scan below. } - // ── Fallback: full UID scan (no CONDSTORE) ─────────────────────── let cached_uids: std::collections::HashSet = match db::pool::open_user_db(&config.data_dir, user_hash) { Ok(conn) => { @@ -508,7 +459,6 @@ async fn fetch_new_after_idle( } }; - // Fetch all UIDs from server. let imap_uids: Option> = match session.uid_fetch("1:*", "UID").await { Ok(stream) => { @@ -539,7 +489,6 @@ async fn fetch_new_after_idle( return (session, ReconcileResult::empty()); }; - // Fetch headers for new messages. let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid); let mut new_count = 0u32; if has_new { @@ -556,7 +505,6 @@ async fn fetch_new_after_idle( .await; } - // Delete cached UIDs no longer on server. let mut deleted_count = 0u32; let deleted_uids: Vec = cached_uids .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) && let Ok(transaction) = header_sync.begin_write(&conn) { @@ -588,11 +535,10 @@ async fn fetch_new_after_idle( let _ = transaction.commit(); } - // Look up the latest message's sender/subject if new messages arrived. - let (latest_sender, latest_subject) = if new_count > 0 { + let (latest_uid, latest_sender, latest_subject) = if new_count > 0 { lookup_latest_message(config, user_hash, folder) } else { - (None, None) + (None, None, None) }; if new_count > 0 || deleted_count > 0 { @@ -611,24 +557,24 @@ async fn fetch_new_after_idle( new_count, flags_updated: 0, deleted_count, + latest_uid, latest_sender, latest_subject, }, ) } -/// Look up the most recent message in the DB for sender/subject details. fn lookup_latest_message( config: &AppConfig, user_hash: &str, folder: &str, -) -> (Option, Option) { +) -> (Option, Option, Option) { 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); if max_uid == 0 { - return (None, None); + return (None, None, None); } match db::messages::get_single_message(&conn, folder, max_uid) { Ok(Some(msg)) => { @@ -644,15 +590,12 @@ fn lookup_latest_message( } else { 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( session: &mut async_imap::Session, user_hash: &str, @@ -706,11 +649,10 @@ async fn fetch_and_store_headers( for fetch in &fetches { let uid = match fetch.uid { Some(u) if u > min_uid => u, - Some(_) => continue, // UID "*" can return the last message + Some(_) => continue, None => continue, }; - // Parse threading headers. let raw_header_bytes = fetch.header(); let parsed_threading = 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}>"))) }); - // Detect Outlook/Exchange reaction emails from headers. let reaction = raw_header_bytes.and_then(|raw| { let header_str = std::str::from_utf8(raw).ok()?; 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"); } else { - // Populate denormalized known_addresses table. if let Err(e) = db::contacts::populate_known_addresses( &conn, from_address, @@ -855,9 +795,6 @@ async fn fetch_and_store_headers( 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( session: &mut async_imap::Session, user_hash: &str, @@ -943,7 +880,7 @@ mod tests { ) .await; let original_task = manager.tasks.get(&key).unwrap().handle.id(); - // A reconnect may subscribe before the old socket finishes closing. + manager .start_idle( key.0.clone(), @@ -963,7 +900,6 @@ mod tests { manager.stop_idle(&key.0, &key.1).await; assert!(manager.tasks.is_empty()); - // A connection arriving after the last disconnect gets a new task. manager .start_idle(key.0.clone(), key.1.clone(), creds, event_bus, config) .await; diff --git a/src/realtime/sync.rs b/src/realtime/sync.rs index b83f50b..5ce780c 100644 --- a/src/realtime/sync.rs +++ b/src/realtime/sync.rs @@ -9,16 +9,8 @@ use crate::realtime::events::{EventBus, MailEvent}; use crate::search::engine::UserIndex; 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; -/// 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( user_hash: String, creds: ImapCredentials, @@ -28,7 +20,7 @@ pub async fn sync_loop( search_engine: Arc, ) { 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; loop { @@ -80,7 +72,6 @@ async fn run_sync( for folder in &folder_snapshots { 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 { Ok(s) => s, 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 { tracing::info!( folder = %folder_name, @@ -110,7 +100,6 @@ async fn run_sync( continue; } - // If CONDSTORE is supported (highest_modseq > 0) and matches cached → skip. let cached_modseq = folder.highest_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 { let folder_changed = sync_condstore( config, @@ -147,7 +135,6 @@ async fn run_sync( continue; } - // ── Tier 3: Full fetch fallback ────────────────────────────── let folder_changed = sync_full( config, user_hash, @@ -167,7 +154,6 @@ async fn run_sync( 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 { tracing::debug!(error = %e, "Deep index phase skipped or failed"); @@ -176,11 +162,8 @@ async fn run_sync( Ok(()) } -/// Batch size for deep indexing per sync cycle. 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( user_hash: &str, creds: &ImapCredentials, @@ -265,14 +248,12 @@ async fn index_message_bodies( Ok(()) } -/// Lightweight snapshot of a cached folder for the sync loop. struct FolderSnapshot { name: String, uid_validity: u32, highest_modseq: u64, } -/// CONDSTORE path: fetch only changed flags, detect deletions via count comparison. #[allow(clippy::too_many_arguments)] async fn sync_condstore( config: &AppConfig, @@ -284,7 +265,6 @@ async fn sync_condstore( status: &crate::imap::types::FolderStatusExtended, event_bus: &EventBus, ) -> Result { - // Fetch only messages whose flags changed since our cached modseq. let (changed, new_modseq) = match imap_client .fetch_changed_flags(creds, folder_name, cached_modseq) .await @@ -380,6 +360,7 @@ async fn sync_condstore( MailEvent::NewMessages { folder: folder_name.to_owned(), count: 0, + latest_uid: None, latest_sender: None, latest_subject: None, }, @@ -401,7 +382,6 @@ async fn sync_condstore( Ok(folder_changed) } -/// Full fetch fallback: fetch all UIDs+FLAGS and reconcile (original behavior). async fn sync_full( config: &AppConfig, user_hash: &str, diff --git a/src/realtime/ws.rs b/src/realtime/ws.rs index ae2e141..7dccb50 100644 --- a/src/realtime/ws.rs +++ b/src/realtime/ws.rs @@ -14,6 +14,13 @@ use crate::realtime::events::EventBus; use crate::realtime::idle::IdleManager; 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 { for segment in cookie_header.split(';') { let trimmed = segment.trim(); @@ -116,7 +123,7 @@ async fn handle_socket_multi_account( host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, - tls_verify: session.imap_tls_verify, + tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }; @@ -145,13 +152,17 @@ async fn handle_socket_multi_account( drop(event_tx); 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 { tokio::select! { Some((account_id, mail_event)) = event_rx.recv() => { let msg = serde_json::json!({ "accountId": account_id, + "eventId": uuid::Uuid::new_v4().to_string(), + "sentAt": unix_time_millis(), "event": mail_event, }); if let Ok(json) = serde_json::to_string(&msg) @@ -164,14 +175,23 @@ async fn handle_socket_multi_account( msg = ws_rx.next() => { match msg { 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::(&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(Err(_)) => break, } } - _ = ping_interval.tick() => { - if ws_tx.send(Message::Ping(vec![].into())).await.is_err() { + _ = heartbeat_interval.tick() => { + 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; } } diff --git a/src/routes/attachments.rs b/src/routes/attachments.rs index 4cf842b..07e1843 100644 --- a/src/routes/attachments.rs +++ b/src/routes/attachments.rs @@ -55,10 +55,6 @@ struct DeleteResponse { 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( Extension(session): Extension, Extension(config): Extension>, @@ -69,7 +65,6 @@ pub async fn upload_attachment( let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .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)?; if existing.is_none() { db::drafts::upsert_draft( @@ -79,7 +74,6 @@ pub async fn upload_attachment( .map_err(AppError::InternalError)?; } - // Build the attachment storage directory. let att_dir = Path::new(&config.data_dir) .join(&session.user_hash) .join("attachments") @@ -186,9 +180,6 @@ pub async fn upload_attachment( .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( Extension(session): Extension, Extension(config): Extension>, @@ -217,9 +208,6 @@ pub async fn get_attachment_content( .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( Extension(session): Extension, Extension(config): Extension>, @@ -229,7 +217,6 @@ pub async fn delete_attachment( let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; - // Get the attachment record so we can find the file path. let attachments = 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) .ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?; - // Delete file from disk (best-effort). let file_path = attachment.file_path.clone(); 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"); } - // Delete from DB. db::drafts::delete_draft_attachment(&conn, &attachment_id).map_err(AppError::InternalError)?; Ok(Json(DeleteResponse { diff --git a/src/routes/auth.rs b/src/routes/auth.rs index eb477fa..21b0079 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -30,7 +30,6 @@ pub async fn auth_policy(Extension(config): Extension>) -> Json &'static str { - // RustBox UI and API are same-origin in RustBox, so Strict is always valid. "Strict" } @@ -138,11 +136,6 @@ fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, Stri 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( Extension(store): Extension>, Extension(config): Extension>, @@ -165,10 +158,6 @@ pub async fn login( .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 .imap_host .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) -> Response { ( StatusCode::OK, @@ -660,11 +645,6 @@ struct ActiveSessionResponse { 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( Extension(store): Extension>, Extension(config): Extension>, @@ -695,7 +675,6 @@ pub async fn list_active_sessions( Json(serde_json::json!({ "sessions": sessions })).into_response() } -/// `DELETE /api/auth/sessions/{id}` — revoke another active session. pub async fn revoke_active_session( Extension(store): Extension>, Extension(session): Extension, @@ -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( Extension(store): Extension>, Extension(session): Extension, @@ -742,10 +719,6 @@ pub async fn revoke_other_sessions( 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( Extension(store): Extension>, Extension(config): Extension>, diff --git a/src/routes/calendar.rs b/src/routes/calendar.rs index 7f17cc8..f14a3c4 100644 --- a/src/routes/calendar.rs +++ b/src/routes/calendar.rs @@ -13,20 +13,13 @@ use crate::db; use crate::db::calendar::{CreateEvent, UpdateCalendarSettings, UpdateEvent}; use crate::error::AppError; -// --------------------------------------------------------------------------- -// Query / request types -// --------------------------------------------------------------------------- - -/// Query parameters for `GET /api/calendar/events`. #[derive(Deserialize)] pub struct ListEventsParams { - /// Start of the time range (ISO 8601). pub start: String, - /// End of the time range (ISO 8601). + pub end: String, } -/// JSON body for `POST /api/calendar/meeting-templates`. #[derive(Deserialize)] pub struct CreateMeetingTemplateRequest { pub name: String, @@ -35,7 +28,6 @@ pub struct CreateMeetingTemplateRequest { pub icon: String, } -/// Response for ICS import. #[derive(Serialize)] pub struct ImportIcsResponse { pub created: usize, @@ -43,10 +35,6 @@ pub struct ImportIcsResponse { pub events: Vec, } -// --------------------------------------------------------------------------- -// Event handlers -// --------------------------------------------------------------------------- - fn ics_escape(value: &str) -> String { value .replace('\\', "\\\\") @@ -162,9 +150,6 @@ fn append_ics_event(out: &mut String, event: &db::calendar::CalendarEvent) { out.push_str("END:VEVENT\r\n"); } -/// `GET /api/calendar/events?start=...&end=...` -/// -/// List events within a time range. pub async fn list_events( Extension(session): Extension, Extension(config): Extension>, @@ -179,9 +164,6 @@ pub async fn list_events( 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( Extension(session): Extension, Extension(config): Extension>, @@ -192,7 +174,7 @@ pub async fn export_ics( .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; 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 { append_ics_event(&mut body, event); @@ -211,9 +193,6 @@ pub async fn export_ics( Ok(response) } -/// `GET /api/calendar/events/{id}` -/// -/// Get a single event by ID. pub async fn get_event( Extension(session): Extension, Extension(config): Extension>, @@ -231,9 +210,6 @@ pub async fn get_event( } } -/// `POST /api/calendar/events` -/// -/// Create a new calendar event. pub async fn create_event( Extension(session): Extension, Extension(config): Extension>, @@ -258,9 +234,6 @@ pub async fn create_event( Ok((axum::http::StatusCode::CREATED, Json(event)).into_response()) } -/// `PUT /api/calendar/events/{id}` -/// -/// Update an existing calendar event. pub async fn update_event( Extension(session): Extension, Extension(config): Extension>, @@ -278,9 +251,6 @@ pub async fn update_event( } } -/// `DELETE /api/calendar/events/{id}` -/// -/// Delete a calendar event. pub async fn delete_event( Extension(session): Extension, Extension(config): Extension>, @@ -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( Extension(session): Extension, Extension(config): Extension>, @@ -321,7 +288,6 @@ pub async fn import_ics( let mut skipped = 0usize; for event_data in parsed { - // Deduplicate by source_uid if let Some(ref uid) = event_data.source_uid && let Ok(Some(_)) = db::calendar::find_event_by_source_uid(&conn, uid) { @@ -346,13 +312,6 @@ pub async fn import_ics( .into_response()) } -// --------------------------------------------------------------------------- -// Settings handlers -// --------------------------------------------------------------------------- - -/// `GET /api/calendar/settings` -/// -/// Get calendar settings. pub async fn get_calendar_settings( Extension(session): Extension, Extension(config): Extension>, @@ -366,9 +325,6 @@ pub async fn get_calendar_settings( Ok(Json(settings).into_response()) } -/// `PUT /api/calendar/settings` -/// -/// Update calendar settings. pub async fn update_calendar_settings( Extension(session): Extension, Extension(config): Extension>, @@ -383,13 +339,6 @@ pub async fn update_calendar_settings( Ok(Json(settings).into_response()) } -// --------------------------------------------------------------------------- -// Meeting template handlers -// --------------------------------------------------------------------------- - -/// `GET /api/calendar/meeting-templates` -/// -/// List all meeting templates. pub async fn list_meeting_templates( Extension(session): Extension, Extension(config): Extension>, @@ -403,9 +352,6 @@ pub async fn list_meeting_templates( 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( Extension(session): Extension, Extension(config): Extension>, @@ -428,9 +374,6 @@ pub async fn create_meeting_template( 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( Extension(session): Extension, Extension(config): Extension>, diff --git a/src/routes/contact_groups.rs b/src/routes/contact_groups.rs index 6e89c61..cca8d1e 100644 --- a/src/routes/contact_groups.rs +++ b/src/routes/contact_groups.rs @@ -13,10 +13,6 @@ use crate::db::contact_groups::ContactGroup; use crate::db::contacts::Contact; use crate::error::AppError; -// --------------------------------------------------------------------------- -// Request / response types -// --------------------------------------------------------------------------- - #[derive(Deserialize)] pub struct CreateGroupBody { pub name: String, @@ -37,11 +33,6 @@ pub struct GroupMembersResponse { pub members: Vec, } -// --------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------- - -/// `GET /api/contact-groups` pub async fn list_groups_handler( Extension(session): Extension, Extension(config): Extension>, @@ -55,7 +46,6 @@ pub async fn list_groups_handler( Ok(Json(ListGroupsResponse { groups }).into_response()) } -/// `POST /api/contact-groups` pub async fn create_group_handler( Extension(session): Extension, Extension(config): Extension>, @@ -76,7 +66,6 @@ pub async fn create_group_handler( Ok(Json(serde_json::json!({ "id": id, "name": name })).into_response()) } -/// `PUT /api/contact-groups/{id}` pub async fn update_group_handler( Extension(session): Extension, Extension(config): Extension>, @@ -101,7 +90,6 @@ pub async fn update_group_handler( } } -/// `DELETE /api/contact-groups/{id}` pub async fn delete_group_handler( Extension(session): Extension, Extension(config): Extension>, @@ -120,7 +108,6 @@ pub async fn delete_group_handler( } } -/// `GET /api/contact-groups/{id}/members` pub async fn list_members_handler( Extension(session): Extension, Extension(config): Extension>, @@ -135,7 +122,6 @@ pub async fn list_members_handler( Ok(Json(GroupMembersResponse { members }).into_response()) } -/// `GET /api/contacts/{contact_id}/groups` pub async fn list_contact_groups_handler( Extension(session): Extension, Extension(config): Extension>, @@ -148,7 +134,6 @@ pub async fn list_contact_groups_handler( Ok(Json(ListGroupsResponse { groups }).into_response()) } -/// `POST /api/contact-groups/{id}/members` pub async fn add_member_handler( Extension(session): Extension, Extension(config): Extension>, @@ -164,7 +149,6 @@ pub async fn add_member_handler( Ok(Json(serde_json::json!({ "status": "ok" })).into_response()) } -/// `DELETE /api/contact-groups/{id}/members/{contact_id}` pub async fn remove_member_handler( Extension(session): Extension, Extension(config): Extension>, diff --git a/src/routes/contacts.rs b/src/routes/contacts.rs index faf5d6b..7561b4d 100644 --- a/src/routes/contacts.rs +++ b/src/routes/contacts.rs @@ -12,34 +12,25 @@ use crate::db; use crate::db::contacts::Contact; use crate::error::AppError; -// --------------------------------------------------------------------------- -// Query / request types -// --------------------------------------------------------------------------- - -/// Query parameters for `GET /api/contacts`. #[derive(Deserialize)] pub struct ListContactsParams { - /// Optional search query to filter by name or email. pub q: Option, - /// Maximum number of results (default 50). + #[serde(default = "default_limit")] pub limit: u32, - /// Offset for pagination (default 0). + #[serde(default)] pub offset: u32, } -/// Query parameters for `GET /api/contacts/autocomplete`. #[derive(Deserialize)] pub struct AutocompleteParams { - /// Search query (required for autocomplete). pub q: Option, - /// Maximum number of suggestions (default 10). + #[serde(default = "default_autocomplete_limit")] pub limit: u32, } -/// JSON body for `POST /api/contacts`. #[derive(Deserialize)] pub struct CreateContactBody { pub id: Option, @@ -91,18 +82,12 @@ fn default_source() -> String { "manual".to_string() } -// --------------------------------------------------------------------------- -// Response types -// --------------------------------------------------------------------------- - -/// Response envelope for `GET /api/contacts`. #[derive(Serialize)] pub struct ListContactsResponse { pub contacts: Vec, pub total_count: usize, } -/// A single autocomplete suggestion. #[derive(Serialize)] pub struct AutocompleteSuggestion { pub email: String, @@ -110,16 +95,11 @@ pub struct AutocompleteSuggestion { pub source: Option, } -/// Response envelope for `GET /api/contacts/autocomplete`. #[derive(Serialize)] pub struct AutocompleteResponse { pub suggestions: Vec, } -// --------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------- - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DuplicateContactGroup { @@ -161,10 +141,6 @@ fn normalize_contact_values(values: Vec, primary: &str) -> Vec { 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( Extension(session): Extension, Extension(config): Extension>, @@ -195,9 +171,6 @@ pub async fn list_contacts_handler( .into_response()) } -/// `POST /api/contacts` -/// -/// Creates or updates a contact. Generates a UUID for `id` if not provided. pub async fn create_contact_handler( Extension(session): Extension, Extension(config): Extension>, @@ -261,10 +234,6 @@ pub async fn create_contact_handler( 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( Extension(session): Extension, Extension(config): Extension>, @@ -313,10 +282,6 @@ pub async fn duplicate_contacts_handler( 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( Extension(session): Extension, Extension(config): Extension>, @@ -427,10 +392,6 @@ pub async fn merge_contacts_handler( 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( Extension(session): Extension, Extension(config): Extension>, @@ -455,11 +416,9 @@ pub async fn autocomplete_handler( let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; - // Search contacts first let contacts = db::contacts::search_contacts(&conn, query, params.limit) .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 suggestions: Vec = 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 { let remaining = params.limit as usize - suggestions.len(); #[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); 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( Extension(session): Extension, Extension(config): Extension>, @@ -564,9 +517,6 @@ pub async fn autocomplete_all_handler( 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( Extension(session): Extension, Extension(config): Extension>, @@ -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( Extension(session): Extension, Extension(config): Extension>, @@ -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( Extension(session): Extension, Extension(config): Extension>, @@ -664,7 +610,6 @@ pub async fn upload_contact_photo_handler( .into_response()) } -/// Return a contact photo without exposing its storage representation. pub async fn get_contact_photo_handler( Extension(session): Extension, Extension(config): Extension>, @@ -684,7 +629,6 @@ pub async fn get_contact_photo_handler( .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( Extension(session): Extension, Extension(config): Extension>, @@ -697,11 +641,6 @@ pub async fn delete_contact_photo_handler( 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 { s.replace('\\', "\\\\") .replace(',', "\\,") @@ -709,7 +648,6 @@ fn vcard_escape(s: &str) -> String { .replace('\n', "\\n") } -/// Serialize a Contact to vCard 3.0 format. fn contact_to_vcard(c: &Contact) -> String { let mut lines = Vec::new(); lines.push("BEGIN:VCARD".to_string()); @@ -761,7 +699,6 @@ fn contact_to_vcard(c: &Contact) -> String { lines.join("\r\n") } -/// A parsed vCard entry with optional fields. struct ParsedVCard { name: String, email: String, @@ -777,11 +714,9 @@ struct ParsedVCard { birthday: String, } -/// Parse vCard data (potentially multiple entries) from a string. fn parse_vcards(data: &str) -> Vec { let mut results = Vec::new(); - // Unfold continuation lines (lines starting with space or tab are continuations). let unfolded = data .replace("\r\n ", "") .replace("\r\n\t", "") @@ -847,11 +782,10 @@ fn parse_vcards(data: &str) -> Vec { continue; } - // Extract field name and value (handle parameters like EMAIL;TYPE=WORK:...) let (field_name, value) = if let Some(colon_pos) = line.find(':') { let raw_key = &line[..colon_pos]; let value = &line[colon_pos + 1..]; - // Strip parameters (e.g., EMAIL;TYPE=WORK -> EMAIL) + let field = if let Some(semi_pos) = raw_key.find(';') { &raw_key[..semi_pos] } else { @@ -862,7 +796,6 @@ fn parse_vcards(data: &str) -> Vec { continue; }; - // Unescape vCard values let value = value .replace("\\n", "\n") .replace("\\,", ",") @@ -870,21 +803,18 @@ fn parse_vcards(data: &str) -> Vec { .replace("\\\\", "\\"); match field_name.as_str() { - "FN" - if name.is_empty() => { - name = value; - } - "N" - // N:Last;First;Middle;Prefix;Suffix — use FN if available, else reconstruct - if name.is_empty() => { - let parts: Vec<&str> = value.splitn(5, ';').collect(); - let last = parts.first().unwrap_or(&"").trim(); - let first = parts.get(1).unwrap_or(&"").trim(); - let combined = format!("{first} {last}").trim().to_string(); - if !combined.is_empty() { - name = combined; - } + "FN" if name.is_empty() => { + name = value; + } + "N" if name.is_empty() => { + let parts: Vec<&str> = value.splitn(5, ';').collect(); + let last = parts.first().unwrap_or(&"").trim(); + let first = parts.get(1).unwrap_or(&"").trim(); + let combined = format!("{first} {last}").trim().to_string(); + if !combined.is_empty() { + name = combined; } + } "EMAIL" => { if email.is_empty() { email = value; @@ -892,27 +822,40 @@ fn parse_vcards(data: &str) -> Vec { emails.push(value); } } - "ORG" - if company.is_empty() => { - company = value; - } - "NOTE" - if notes.is_empty() => { - notes = value; - } + "ORG" if company.is_empty() => { + company = value; + } + "NOTE" if notes.is_empty() => { + notes = value; + } "TEL" => { - if phone.is_empty() { phone = value; } - else if !phones.iter().any(|item| item == &value) { phones.push(value); } + if phone.is_empty() { + 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" => { - 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(", "); - if address.is_empty() { address = parsed; } - else if !parsed.is_empty() && !addresses.iter().any(|item| item == &parsed) { addresses.push(parsed); } + if address.is_empty() { + 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 { results } -/// `GET /api/contacts/export` -/// -/// Export all contacts as a .vcf file download. pub async fn export_contacts_handler( Extension(session): Extension, Extension(config): Extension>, @@ -949,9 +889,6 @@ pub async fn export_contacts_handler( .unwrap()) } -/// `GET /api/contacts/{id}/export` -/// -/// Export a single contact as a .vcf file download. pub async fn export_single_contact_handler( Extension(session): Extension, Extension(config): Extension>, @@ -966,11 +903,7 @@ pub async fn export_single_contact_handler( match contact { Some(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 filename = if safe_name.is_empty() { "contact.vcf".to_string() @@ -991,7 +924,6 @@ pub async fn export_single_contact_handler( } } -/// Import response with counts. #[derive(Serialize)] pub struct ImportResponse { pub created: usize, @@ -999,15 +931,11 @@ pub struct ImportResponse { pub skipped: usize, } -/// `POST /api/contacts/import` -/// -/// Import contacts from an uploaded .vcf file (multipart form data). pub async fn import_contacts_handler( Extension(session): Extension, Extension(config): Extension>, mut multipart: Multipart, ) -> Result { - // Read the uploaded file content let mut vcf_content = String::new(); if let Some(field) = multipart .next_field() @@ -1050,7 +978,6 @@ pub async fn import_contacts_handler( match existing { Some(mut c) => { - // Merge only empty fields (don't overwrite manual edits). let mut changed = false; if c.name.is_empty() && !entry.name.is_empty() { c.name = entry.name.clone(); diff --git a/src/routes/diagnostics.rs b/src/routes/diagnostics.rs index 88e5ac0..d6e5b6c 100644 --- a/src/routes/diagnostics.rs +++ b/src/routes/diagnostics.rs @@ -70,10 +70,6 @@ pub struct SearchRebuildResponse { 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( Extension(session): Extension, Extension(imap_client): Extension>, @@ -128,10 +124,6 @@ pub async fn imap_diagnostics( .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( Extension(session): Extension, Extension(smtp_client): Extension>, @@ -159,10 +151,6 @@ pub async fn smtp_diagnostics( .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( Extension(session): Extension, Extension(imap_client): Extension>, @@ -213,10 +201,6 @@ fn index_sources_to_messages( .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( Extension(session): Extension, Extension(config): Extension>, @@ -265,10 +249,6 @@ pub async fn sync_diagnostics( 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( Extension(session): Extension, Extension(config): Extension>, @@ -311,10 +291,6 @@ pub async fn search_diagnostics( 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( Extension(session): Extension, Extension(config): Extension>, diff --git a/src/routes/display_preferences.rs b/src/routes/display_preferences.rs index fe03dd9..d75417d 100644 --- a/src/routes/display_preferences.rs +++ b/src/routes/display_preferences.rs @@ -17,7 +17,6 @@ fn map_update_error(message: String) -> AppError { } } -/// `GET /api/settings/display` pub async fn get_display_preferences( Extension(session): Extension, Extension(config): Extension>, @@ -30,7 +29,6 @@ pub async fn get_display_preferences( Ok(Json(prefs).into_response()) } -/// `PUT /api/settings/display` pub async fn update_display_preferences( Extension(session): Extension, Extension(config): Extension>, diff --git a/src/routes/drafts.rs b/src/routes/drafts.rs index ae325e1..00b4681 100644 --- a/src/routes/drafts.rs +++ b/src/routes/drafts.rs @@ -13,10 +13,6 @@ use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; use crate::smtp::client::AttachmentData; -// --------------------------------------------------------------------------- -// Request / Response types -// --------------------------------------------------------------------------- - #[derive(Debug, Deserialize)] pub struct UpsertDraftRequest { pub id: String, @@ -47,8 +43,7 @@ pub struct UpsertDraftRequest { pub pgp_attach_public_key: bool, #[serde(default)] 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)] pub sync_imap: bool, } @@ -116,11 +111,6 @@ struct DeleteResponse { status: String, } -// --------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------- - -/// `POST /api/drafts` — Create or update a draft. pub async fn upsert_draft_handler( Extension(session): Extension, Extension(config): Extension>, @@ -174,10 +164,6 @@ pub async fn upsert_draft_handler( ) .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() { let imap_creds = session_imap_credentials(&session); let drafts_folder = db::folders::find_folder_by_attribute(&conn, "\\Drafts") @@ -353,7 +339,6 @@ fn load_snapshot_attachments( .collect() } -/// Build a minimal RFC822 message from draft fields for IMAP APPEND. fn build_draft_rfc822( req: &UpsertDraftRequest, from_email: &str, @@ -377,7 +362,6 @@ fn build_draft_rfc822( draft_id, )); - // Parse comma-separated address lists. for addr in req.to.split(',').map(str::trim).filter(|s| !s.is_empty()) { if let Ok(mailbox) = addr.parse::() { builder = builder.to(mailbox); @@ -473,7 +457,6 @@ fn build_draft_rfc822( Ok(email.formatted()) } -/// `GET /api/drafts` — List all drafts. pub async fn list_drafts_handler( Extension(session): Extension, Extension(config): Extension>, @@ -497,7 +480,6 @@ pub async fn list_drafts_handler( 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( Extension(session): Extension, Extension(config): Extension>, @@ -549,7 +531,6 @@ pub async fn get_draft_handler( .into_response()) } -/// `DELETE /api/drafts/{id}` — Delete a draft and its attachments. pub async fn delete_draft_handler( Extension(session): Extension, Extension(config): Extension>, @@ -575,7 +556,6 @@ pub async fn delete_draft_handler( return Err(AppError::NotFound("Draft not found".to_string())); } - // Clean up attachment files from disk. let att_dir = Path::new(&config.data_dir) .join(&session.user_hash) .join("attachments") diff --git a/src/routes/folder_mgmt.rs b/src/routes/folder_mgmt.rs index 4894f5a..07214af 100644 --- a/src/routes/folder_mgmt.rs +++ b/src/routes/folder_mgmt.rs @@ -12,10 +12,6 @@ use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; use crate::search::engine::SearchEngine; -// --------------------------------------------------------------------------- -// Request / Response types -// --------------------------------------------------------------------------- - #[derive(Deserialize)] pub struct CreateFolderRequest { pub name: String, @@ -36,19 +32,12 @@ struct MessageResponse { status: &'static str, } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Folder names that must not be renamed or deleted. 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 { 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> { if name.trim().is_empty() { return Err(AppError::BadRequest( @@ -73,7 +62,6 @@ fn validate_folder_name(name: &str) -> Result<(), AppError> { Ok(()) } -/// Build IMAP credentials from the session and app configuration. fn build_creds(session: &SessionState, _config: &AppConfig) -> Result { Ok(ImapCredentials { host: session.imap_host.clone(), @@ -85,11 +73,6 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result, Extension(config): Extension>, @@ -100,13 +83,11 @@ pub async fn create_folder( let creds = build_creds(&session, &config)?; - // Create the folder on the IMAP server (also subscribes). imap_client .create_folder(&creds, &body.name) .await .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) .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()) } -/// `PATCH /api/folders/:name` -- rename an existing folder. pub async fn rename_folder( Extension(session): Extension, Extension(config): Extension>, @@ -139,7 +119,6 @@ pub async fn rename_folder( .await .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) .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()) } -/// `DELETE /api/folders/:name` -- delete a folder. pub async fn delete_folder( Extension(session): Extension, Extension(config): Extension>, @@ -171,7 +149,6 @@ pub async fn delete_folder( .await .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) .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()) } -/// `PATCH /api/folders/:name/subscribe` -- toggle folder subscription. pub async fn subscribe_folder( Extension(session): Extension, Extension(config): Extension>, @@ -208,7 +184,6 @@ pub async fn subscribe_folder( .into_response()) } -/// `POST /api/folders/:name/mark-all-read` -- add \Seen to every message. pub async fn mark_all_read( Extension(session): Extension, Extension(config): Extension>, @@ -237,7 +212,6 @@ pub async fn mark_all_read( Ok(Json(MessageResponse { status: "read" }).into_response()) } -/// `POST /api/folders/:name/empty` -- permanently remove every message. pub async fn empty_folder( Extension(session): Extension, Extension(config): Extension>, @@ -281,10 +255,6 @@ pub async fn empty_folder( Ok(Json(MessageResponse { status: "emptied" }).into_response()) } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; diff --git a/src/routes/folders.rs b/src/routes/folders.rs index 7803ef5..2ddd804 100644 --- a/src/routes/folders.rs +++ b/src/routes/folders.rs @@ -10,13 +10,11 @@ use crate::db; use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; -/// Response envelope for `GET /api/folders`. #[derive(Serialize)] struct FoldersResponse { folders: Vec, } -/// A single folder in the response. #[derive(Serialize)] struct FolderEntry { name: String, @@ -27,24 +25,16 @@ struct FolderEntry { unread_count: u32, } -/// How many seconds the folder list cache is considered fresh. 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( Extension(session): Extension, Extension(config): Extension>, Extension(imap_client): Extension>, ) -> Result { - // Open the per-user database. let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .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) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; @@ -53,20 +43,16 @@ pub async fn list_folders( host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, - tls_verify: session.imap_tls_verify, + tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }; - // Fetch folders from IMAP server. let imap_folders = imap_client .list_folders(&creds) .await .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 { let flags_csv = folder.attributes.join(","); db::folders::insert_folder_if_new( @@ -78,20 +64,14 @@ pub async fn list_folders( .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; } - // Remove stale folders that no longer exist on the server. let current_names: Vec = imap_folders.iter().map(|f| f.name.clone()).collect(); db::folders::remove_stale_folders(&conn, ¤t_names) .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) .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) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; 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) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; - // Build response from cached data (includes any stored counts). let folders: Vec = cached .into_iter() .map(|f| { diff --git a/src/routes/health.rs b/src/routes/health.rs index 37547cc..f4d4e0d 100644 --- a/src/routes/health.rs +++ b/src/routes/health.rs @@ -13,21 +13,12 @@ pub struct HealthResponse { 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 { Json(HealthResponse { 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>) -> Response { let ready = fs::create_dir_all(&config.data_dir).is_ok() && fs::metadata(&config.data_dir).is_ok_and(|metadata| metadata.is_dir()); diff --git a/src/routes/identities.rs b/src/routes/identities.rs index f1c227a..2258d67 100644 --- a/src/routes/identities.rs +++ b/src/routes/identities.rs @@ -10,7 +10,6 @@ use crate::db; use crate::db::identities::{CreateIdentity, UpdateIdentity}; use crate::error::AppError; -/// `GET /api/identities` — list all sender identities. pub async fn list_identities_handler( Extension(session): Extension, Extension(config): Extension>, @@ -23,7 +22,6 @@ pub async fn list_identities_handler( Ok(Json(serde_json::json!({ "identities": identities })).into_response()) } -/// `GET /api/identities/:id` — get a single identity. pub async fn get_identity_handler( Extension(session): Extension, Extension(config): Extension>, @@ -40,7 +38,6 @@ pub async fn get_identity_handler( } } -/// `POST /api/identities` — create a new identity. pub async fn create_identity_handler( Extension(session): Extension, Extension(config): Extension>, @@ -60,7 +57,6 @@ pub async fn create_identity_handler( Ok((axum::http::StatusCode::CREATED, Json(identity)).into_response()) } -/// `PUT /api/identities/:id` — update an identity. pub async fn update_identity_handler( Extension(session): Extension, Extension(config): Extension>, @@ -79,7 +75,6 @@ pub async fn update_identity_handler( } } -/// `DELETE /api/identities/:id` — delete an identity. pub async fn delete_identity_handler( Extension(session): Extension, Extension(config): Extension>, diff --git a/src/routes/messages/mod.rs b/src/routes/messages/mod.rs index c9794ad..bc1120a 100644 --- a/src/routes/messages/mod.rs +++ b/src/routes/messages/mod.rs @@ -19,10 +19,6 @@ use crate::imap::client::{ImapClient, ImapCredentials}; use crate::realtime::events::{EventBus, MailEvent}; use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex}; -// --------------------------------------------------------------------------- -// Helper: build IMAP credentials from session + config -// --------------------------------------------------------------------------- - fn build_creds(session: &SessionState, _config: &AppConfig) -> Result { Ok(ImapCredentials { host: session.imap_host.clone(), @@ -34,26 +30,11 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result bool { if flag.is_empty() || flag.len() > 64 { return false; } - // Strip optional leading backslash (system flags like \Seen). + let atom = flag.strip_prefix('\\').unwrap_or(flag); !atom.is_empty() && atom @@ -70,19 +51,8 @@ fn validate_flags(flags: &[String]) -> Result<(), AppError> { Ok(()) } -// --------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------- - -/// How many seconds a folder's message cache is considered fresh. 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( Extension(session): Extension, Extension(config): Extension>, @@ -154,7 +124,6 @@ pub async fn list_messages( if !folder_fresh { let creds = build_creds(&session, &config)?; - // Do a lightweight IMAP SELECT to get folder status. let status = imap_client .folder_status(&creds, &folder) .await @@ -201,8 +170,6 @@ pub async fn list_messages( || folder_invalidated; 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 { let recent_start = status.uid_next.saturating_sub(100).max(1); let recent_range = format!("{recent_start}:*"); @@ -233,7 +200,6 @@ pub async fn list_messages( .await .map_err(AppError::InternalError)?; - // Spawn background task to fetch the remaining older headers. if recent_start > 1 { let remaining_range = (1, recent_start - 1); tokio::spawn(sync_remaining_headers(BackgroundSyncParams { @@ -252,8 +218,6 @@ pub async fn list_messages( syncing = true; } } 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)); tokio::spawn(sync_remaining_headers(BackgroundSyncParams { @@ -357,11 +321,6 @@ pub async fn list_messages( .into_response()) } -// --------------------------------------------------------------------------- -// Helpers for eager loading -// --------------------------------------------------------------------------- - -/// Upsert fetched headers into the database and index them for search. fn upsert_and_index_headers( conn: &rusqlite::Transaction<'_>, folder: &str, @@ -369,7 +328,6 @@ fn upsert_and_index_headers( search_engine: &Arc, user_hash: &str, ) -> Result<(), AppError> { - // Caller holds a checked HeaderSync write transaction through indexing. let sync_keywords = db::mail_preferences::get_preferences(conn) .map(|prefs| prefs.sync_imap_keywords) .unwrap_or(false); @@ -411,7 +369,6 @@ fn upsert_and_index_headers( ) .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) .map_err(|e| AppError::InternalError(format!("Known addresses error: {e}")))?; if sync_keywords { @@ -420,7 +377,6 @@ fn upsert_and_index_headers( } } - // Index for search (skip Spam/Junk/Trash). if !headers.is_empty() && !UserIndex::is_excluded_folder(folder) && let Ok(user_index) = search_engine.open_user_index(user_hash) @@ -452,7 +408,6 @@ fn upsert_and_index_headers( Ok(()) } -/// Bound each FETCH by existing messages, not by the size of gaps between UIDs. fn header_chunks(uids: &[u32]) -> impl Iterator + '_ { uids.chunks(250).map(|chunk| { chunk @@ -463,7 +418,6 @@ fn header_chunks(uids: &[u32]) -> impl Iterator + '_ { }) } -/// Parameters for a background sync task, bundled to avoid too-many-arguments. struct BackgroundSyncParams { creds: ImapCredentials, folder: String, @@ -478,8 +432,6 @@ struct BackgroundSyncParams { 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) { let BackgroundSyncParams { creds, @@ -567,9 +519,7 @@ async fn sync_remaining_headers(params: BackgroundSyncParams) { Ok(superseded) }) .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)) { event_bus .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( Extension(session): Extension, Extension(config): Extension>, @@ -592,16 +538,12 @@ pub async fn get_message( ) -> Result { let creds = build_creds(&session, &config)?; - // Open the per-user database. let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; - // Check SQLite cache first. let cached_body = db::messages::get_cached_body(&conn, &folder, uid) .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 fetched_body = usable_cache.is_none(); @@ -638,15 +580,11 @@ pub async fn get_message( ) } } else { - // Fetch from IMAP. let body = imap_client .fetch_body(&creds, &folder, uid) .await .map_err(|e| match e { 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::folders::invalidate_folder_freshness(&conn, &folder); 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}")) } 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::folders::invalidate_folder_freshness(&conn, &folder); 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 attachment_meta: Vec = body @@ -683,9 +617,6 @@ pub async fn get_message( }) .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| { for att in &body.attachments { if let Some(ref cid) = att.content_id { @@ -701,7 +632,6 @@ pub async fn get_message( html }); - // Serialize attachment metadata for caching. let att_json = serde_json::to_string(&attachment_meta).ok(); 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) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let msg = match msg { Some(m) => m, 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 subject = parsed .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 && let Some(ref text) = body_text && !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 { db::messages::get_full_thread(&conn, message_id, msg.references_header.as_deref()) .unwrap_or_default() @@ -901,9 +823,6 @@ pub async fn get_message( .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( Extension(session): Extension, Extension(config): Extension>, @@ -915,7 +834,6 @@ pub async fn update_flags( 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(); if body.add { @@ -930,7 +848,6 @@ pub async fn update_flags( .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) .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) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; - // Refresh unread count after flag change. db::folders::refresh_unread_count(&conn, &folder) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; 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( Extension(session): Extension, Extension(config): Extension>, @@ -991,8 +903,6 @@ pub async fn move_message_handler( 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 conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .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")) .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 .as_ref() .and_then(|message| message.message_id.as_deref()) @@ -1022,7 +928,6 @@ pub async fn move_message_handler( None }; - // Move on IMAP server. imap_client .move_message(&creds, &body.from_folder, body.uid, &body.to_folder) .await @@ -1068,14 +973,9 @@ pub async fn move_message_handler( let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .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) .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 from_folder = body.from_folder.clone(); let uid = body.uid; @@ -1085,25 +985,20 @@ pub async fn move_message_handler( }) .await; if !matches!(search_result, Ok(Ok(()))) { - // The IMAP move already succeeded: do not trigger a false client rollback. tracing::warn!( ?search_result, "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) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; - // Bump destination folder unread count if the moved message was unread. if was_unread { db::folders::adjust_unread_count(&conn, &body.to_folder, 1) .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) .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()) } -/// `POST /api/messages/copy` -/// -/// Copies a message to another IMAP folder and invalidates the destination cache. pub async fn copy_message_handler( Extension(session): Extension, Extension(config): Extension>, @@ -1146,9 +1038,6 @@ pub async fn copy_message_handler( 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( Extension(session): Extension, Extension(config): Extension>, @@ -1157,12 +1046,10 @@ pub async fn download_attachment( ) -> Result { let creds = build_creds(&session, &config)?; - // Parse the attachment index. let index: usize = attachment_id .parse() .map_err(|_| AppError::BadRequest(format!("Invalid attachment id: {attachment_id}")))?; - // Fetch the full message body from IMAP. let body = imap_client .fetch_body(&creds, &folder, uid) .await @@ -1173,28 +1060,21 @@ pub async fn download_attachment( other => AppError::ServiceUnavailable(format!("IMAP error: {other}")), })?; - // Find the attachment by index. let attachment = body.attachments.into_iter().nth(index).ok_or_else(|| { AppError::NotFound(format!( "Attachment {attachment_id} not found on message UID {uid}" )) })?; - // Build the response with appropriate headers. let raw_filename = attachment .filename .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 .chars() .filter(|c| !c.is_ascii_control()) .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() { format!("attachment_{index}") } else { @@ -1203,8 +1083,6 @@ pub async fn download_attachment( let content_type = 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" || content_type.starts_with("image/") || 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; -/// `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( Extension(session): Extension, Extension(config): Extension>, @@ -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() .parse(bytes.as_ref()) .is_none() @@ -1314,7 +1184,6 @@ pub async fn import_eml( .into_response()) } -/// Download the complete RFC822 source as an .eml file. pub async fn download_eml_source( Extension(session): Extension, Extension(config): Extension>, @@ -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( Extension(session): Extension, Extension(config): Extension>, @@ -1413,19 +1279,16 @@ pub async fn delete_message_handler( ) -> Result { let creds = build_creds(&session, &config)?; - // Expunge on IMAP server. imap_client .expunge_message(&creds, &folder, uid) .await .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) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; db::messages::remove_after_mutation(&conn, &folder, uid) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; - // Refresh unread count for folder. db::folders::refresh_unread_count(&conn, &folder) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; @@ -1539,8 +1402,6 @@ mod sync_tests { 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; let imap_client: Arc = client.clone(); let response = if move_message { @@ -1583,7 +1444,7 @@ mod sync_tests { "superseded sync must request fresh missing-UID reconciliation" ); assert!(events.try_recv().is_err()); - // A move's index deletion also must not be undone by rejected headers. + if move_message { let index = search_engine.open_user_index(user_hash).unwrap(); 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 transaction = next_sync.begin_write(&conn).unwrap(); upsert_and_index_headers( @@ -1744,8 +1603,6 @@ mod sync_tests { 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) .filter(|uid| other_folder || *uid != 950) .map(sync_header) @@ -1868,7 +1725,6 @@ mod sync_tests { #[tokio::test] 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)] { let data_dir = tempfile::tempdir().unwrap(); let path = data_dir.path().to_str().unwrap(); @@ -1893,7 +1749,7 @@ mod sync_tests { host: "localhost".into(), port: 143, tls: false, - tls_verify: false, + tls_verify: false, email: "test@example.com".into(), password: "test".into(), }, @@ -1963,7 +1819,7 @@ mod sync_tests { host: "localhost".into(), port: 143, tls: false, - tls_verify: false, + tls_verify: false, email: "test@example.com".into(), password: "test".into(), }, diff --git a/src/routes/messages/types.rs b/src/routes/messages/types.rs index b8828c2..987ed7c 100644 --- a/src/routes/messages/types.rs +++ b/src/routes/messages/types.rs @@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize}; pub use crate::email_theme::EmailTheme; -/// Default page size for paginated list queries. pub fn default_per_page() -> u32 { 50 } @@ -11,7 +10,6 @@ pub fn default_sort() -> String { "date_desc".to_string() } -/// Query parameters for `GET /api/folders/:folder/messages`. #[derive(Deserialize)] pub(crate) struct ListMessagesQuery { #[serde(default)] @@ -22,14 +20,12 @@ pub(crate) struct ListMessagesQuery { pub(crate) sort: String, } -/// Request body for `PATCH /api/messages/:folder/:uid/flags`. #[derive(Deserialize)] pub(crate) struct UpdateFlagsRequest { pub(crate) flags: Vec, pub(crate) add: bool, } -/// Request body for `POST /api/messages/move`. #[derive(Deserialize)] pub(crate) struct MoveMessageRequest { pub(crate) from_folder: String, @@ -44,7 +40,6 @@ pub(crate) struct CopyMessageRequest { pub(crate) uid: u32, } -/// Response envelope for `GET /api/folders/:folder/messages`. #[derive(Serialize)] pub(crate) struct ListMessagesResponse { pub(crate) messages: Vec, @@ -55,7 +50,6 @@ pub(crate) struct ListMessagesResponse { pub(crate) flagged_on_top: bool, } -/// A message summary in the list response. #[derive(Serialize, Clone)] pub struct MessageSummary { pub uid: u32, @@ -73,20 +67,16 @@ pub struct MessageSummary { pub tags: Vec, } -/// An email address entry for the detail response. #[derive(Serialize, Deserialize, Clone)] pub(crate) struct AddressEntry { pub(crate) name: Option, pub(crate) address: String, } -/// Parse a JSON-encoded address list string (e.g. from the SQLite cache) into -/// a `Vec`. Returns an empty vec on parse failure. pub(crate) fn parse_address_list(json_str: &str) -> Vec { serde_json::from_str(json_str).unwrap_or_default() } -/// Split a comma-separated flags string into a `Vec`. pub(crate) fn parse_flags(flags_csv: &str) -> Vec { if flags_csv.is_empty() { return vec![]; @@ -98,7 +88,6 @@ pub(crate) fn parse_flags(flags_csv: &str) -> Vec { .collect() } -/// Response for `GET /api/messages/:folder/:uid`. #[derive(Serialize)] pub(crate) struct MessageDetailResponse { pub(crate) uid: u32, @@ -122,7 +111,6 @@ pub(crate) struct MessageDetailResponse { pub(crate) email_theme: Option, } -/// A message summary within a thread. #[derive(Serialize)] pub(crate) struct ThreadMessage { pub(crate) uid: u32, @@ -141,7 +129,6 @@ pub(crate) struct ThreadMessage { pub(crate) snippet: String, } -/// Attachment metadata (without the binary data). #[derive(Serialize, Deserialize)] pub(crate) struct AttachmentMeta { pub(crate) id: String, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 9837ca5..170af2d 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -48,14 +48,6 @@ use crate::realtime::events::EventBus; use crate::realtime::idle::IdleManager; 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` is unavailable (e.g. in -/// unit tests). #[derive(Debug, Clone)] struct ProxyAwareIpExtractor { trusted_proxies: Vec, @@ -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( config: Arc, store: Arc, @@ -140,7 +110,6 @@ pub fn create_router( event_bus: Arc, idle_manager: Arc, ) -> Router { - // Rate-limit login: replenish 1 token every 12 s, burst of 5. let governor_conf = GovernorConfigBuilder::default() .key_extractor(ProxyAwareIpExtractor { trusted_proxies: config.parsed_trusted_proxies(), @@ -150,15 +119,12 @@ pub fn create_router( .finish() .expect("valid governor config"); - // Public auth route: GovernorLayer (outermost) -> CSRF -> handler. let public_auth = Router::new() .route("/login", post(auth::login)) .route("/policy", get(auth::auth_policy)) .layer(middleware::from_fn(csrf_protection)) .layer(GovernorLayer::new(governor_conf)); - // Browser-bound routes (no auth_guard, no rate limit). - // These routes only need the browser cookie, not full auth. let browser_routes = Router::new() .route("/accounts", get(auth::list_accounts)) .route( @@ -166,7 +132,6 @@ pub fn create_router( delete(auth::remove_account).layer(middleware::from_fn(csrf_protection)), ); - // Protected auth routes (auth_guard + CSRF). let protected_auth = Router::new() .route("/session", get(auth::get_session)) .route("/sessions", get(auth::list_active_sessions)) @@ -181,9 +146,9 @@ pub fn create_router( .merge(browser_routes) .merge(protected_auth); - // Protected data routes (auth_guard + CSRF). - // Multipart framing needs a small allowance on top of the configured per-file limit. - let attachment_body_limit = config.max_attachment_size_bytes().saturating_add(1024 * 1024); + let attachment_body_limit = config + .max_attachment_size_bytes() + .saturating_add(1024 * 1024); let protected_data = Router::new() .route( "/folders", @@ -240,7 +205,8 @@ pub fn create_router( ) .route( "/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( "/drafts/{draft_id}/attachments/{attachment_id}", @@ -482,8 +448,6 @@ pub fn create_router( .layer(middleware::from_fn(auth_guard)) .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 api_router = Router::new() @@ -493,21 +457,14 @@ pub fn create_router( .merge(ws_route) .merge(protected_data); - // RustBox serves API and UI from the same Rust process. let inner = Router::new() .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)) .fallback(crate::ui::serve_ui); - // Use the same base path for routing and authentication cookies. let router = match config.normalized_base_path() { "/" => inner, 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('/')); Router::new() .route(&base_path_slash, get(crate::ui::serve_ui)) diff --git a/src/routes/notification_preferences.rs b/src/routes/notification_preferences.rs index 7515fd0..08dfff6 100644 --- a/src/routes/notification_preferences.rs +++ b/src/routes/notification_preferences.rs @@ -9,7 +9,6 @@ use crate::db; use crate::db::notification_preferences::UpdateNotificationPreferences; use crate::error::AppError; -/// `GET /api/settings/notifications` pub async fn get_notification_preferences( Extension(session): Extension, Extension(config): Extension>, @@ -23,7 +22,6 @@ pub async fn get_notification_preferences( Ok(Json(prefs).into_response()) } -/// `PUT /api/settings/notifications` pub async fn update_notification_preferences( Extension(session): Extension, Extension(config): Extension>, diff --git a/src/routes/quota.rs b/src/routes/quota.rs index 70db429..e361efa 100644 --- a/src/routes/quota.rs +++ b/src/routes/quota.rs @@ -13,17 +13,11 @@ use crate::imap::client::{ImapClient, ImapCredentials}; #[derive(Serialize)] struct QuotaResponse { - /// Storage used in bytes, or null if unavailable. usage_bytes: Option, - /// Storage limit in bytes, or null if unlimited/unavailable. + limit_bytes: Option, } -/// `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( Extension(session): Extension, Extension(config): Extension>, @@ -38,7 +32,6 @@ pub async fn get_quota( password: session.password.clone(), }; - // Try IMAP GETQUOTAROOT first. let quota = imap_client.get_quota(&creds).await.unwrap_or(None); let resp = match quota { @@ -63,8 +56,7 @@ pub async fn get_quota( }) .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 user_hash = session.user_hash.clone(); let folders: Vec = 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!("Database error: {e}")))?; - // Fetch sizes with a 90s overall timeout. let total = tokio::time::timeout( Duration::from_secs(90), crate::imap::pool::background(async { diff --git a/src/routes/search.rs b/src/routes/search.rs index 029f77c..f25108b 100644 --- a/src/routes/search.rs +++ b/src/routes/search.rs @@ -11,11 +11,6 @@ use crate::db; use crate::error::AppError; use crate::search::engine::{SearchEngine, SearchQuery}; -// --------------------------------------------------------------------------- -// Search query parser -// --------------------------------------------------------------------------- - -/// Intermediate result of parsing structured operators out of a query string. struct ParsedQuery { text: String, subject_only: Option, @@ -29,11 +24,6 @@ struct ParsedQuery { flagged: Option, } -/// 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 { let mut text_parts: Vec = Vec::new(); let mut subject_only: Option = None; @@ -88,7 +78,6 @@ fn parse_search_query(input: &str) -> ParsedQuery { flagged = Some(false) } _ => { - // Unknown operator, treat as plain text 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 { let mut tokens = Vec::new(); let mut chars = input.chars().peekable(); @@ -126,11 +112,10 @@ fn tokenize_query(input: &str) -> Vec { } chars.next(); } else if ch == '"' { - // Start of a quoted section - chars.next(); // consume opening quote + chars.next(); while let Some(&qch) = chars.peek() { if qch == '"' { - chars.next(); // consume closing quote + chars.next(); break; } current.push(qch); @@ -149,12 +134,11 @@ fn tokenize_query(input: &str) -> Vec { tokens } -/// Split a token into (operator, value) if it matches `operator:value` pattern. fn split_operator(token: &str) -> Option<(String, String)> { if let Some(colon_pos) = token.find(':') { let op = &token[..colon_pos]; 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() { return Some((op.to_string(), val.to_string())); } @@ -162,15 +146,12 @@ fn split_operator(token: &str) -> Option<(String, String)> { 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)> { 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)) } -/// Parse a `YYYY-MM-DD` date string into a start-of-day epoch (UTC). fn parse_date_start(date_str: &str) -> Option { let parts: Vec<&str> = date_str.split('-').collect(); if parts.len() != 3 { @@ -184,16 +165,12 @@ fn parse_date_start(date_str: &str) -> Option { 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; - // Days from full years for y in 1970..year { total_days += if is_leap_year(y) { 366 } else { 365 }; } - // Days from full months in the target year let days_in_months = [ 31, 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 } -// --------------------------------------------------------------------------- -// Query parameters -// --------------------------------------------------------------------------- - -/// Query parameters for `GET /api/search`. #[derive(Deserialize)] pub struct SearchParams { - /// The search text (required, must not be empty). pub q: Option, - /// Optional folder filter. + pub folder: Option, - /// Optional subject filter. + pub subject: Option, - /// Optional from address filter. + pub from: Option, - /// Optional to address filter. + pub to: Option, - /// Optional date range start (Unix epoch seconds). + pub date_from: Option, - /// Optional date range end (Unix epoch seconds). + pub date_to: Option, - /// Optional attachment filter. + pub has_attachment: Option, - /// Optional unread/read filter. + pub unread: Option, - /// Optional flagged/unflagged filter. + pub flagged: Option, - /// Maximum number of results (default 50). + #[serde(default = "default_limit")] pub limit: usize, - /// Offset for pagination (default 0). + #[serde(default)] pub offset: usize, - /// Sort order: "date_desc" (default) or "date_asc". + #[serde(default = "default_sort")] pub sort: String, - /// Materialize every matching cached result. Intended for explicit - /// "select all search results" bulk actions, not normal rendering. + #[serde(default)] pub all: bool, } @@ -271,11 +241,6 @@ fn default_limit() -> usize { 10_000 } -// --------------------------------------------------------------------------- -// Response types -// --------------------------------------------------------------------------- - -/// Response envelope for `GET /api/search`. #[derive(Serialize)] pub struct SearchResponse { pub results: Vec, @@ -283,7 +248,6 @@ pub struct SearchResponse { pub query: String, } -/// A single search result item enriched with message metadata from SQLite. #[derive(Serialize)] pub struct SearchResultItem { pub uid: u32, @@ -299,21 +263,12 @@ pub struct SearchResultItem { 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( Extension(session): Extension, Extension(config): Extension>, Extension(search_engine): Extension>, Query(params): Query, ) -> Result { - // Validate that `q` is provided and non-empty. let query_text = params.q.as_deref().unwrap_or("").trim().to_string(); 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 search_offset = params.offset; - // Parse structured operators from the query string. let parsed = parse_search_query(&query_text); let folder = params.folder.or(parsed.folder); @@ -349,7 +303,6 @@ pub async fn search_messages( let unread = params.unread.or(parsed.unread); 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 user_hash = session.user_hash.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!("Search error: {e}")))?; - // Collect SQLite results as SearchResultItems. let mut seen = std::collections::HashSet::new(); let mut results: Vec = sqlite_results .into_iter() @@ -407,8 +359,6 @@ pub async fn search_messages( }) .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() && results.len() < limit && 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 }, }) { - // Resolve Tantivy UIDs via SQLite in spawn_blocking. let data_dir2 = config.data_dir.clone(); let user_hash2 = session.user_hash.clone(); 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"; results.sort_by(|a, b| { 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); Ok(Json(SearchResponse { @@ -566,10 +513,6 @@ pub async fn delete_saved_search( } } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; @@ -628,7 +571,7 @@ mod tests { assert_eq!(p.text, "news"); assert!(p.date_from.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_to, Some(1_705_276_800 + 86_399)); } @@ -637,9 +580,9 @@ mod tests { fn parse_after_before() { let p = parse_search_query("after:2024-01-01 before:2024-06-01 hello"); assert_eq!(p.text, "hello"); - // 2024-01-01 = 1704067200 + assert_eq!(p.date_from, Some(1_704_067_200)); - // 2024-06-01 = 1717200000 + assert_eq!(p.date_to, Some(1_717_200_000)); } @@ -688,7 +631,6 @@ mod tests { #[test] fn parse_empty_value_not_operator() { - // "from:" with no value should not be treated as an operator let p = parse_search_query("hello from:"); assert_eq!(p.text, "hello from:"); assert!(p.from.is_none()); @@ -696,9 +638,8 @@ mod tests { #[test] fn date_parsing_basic() { - // 2024-01-01 00:00:00 UTC 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("2024-13-01"), None); } diff --git a/src/routes/send.rs b/src/routes/send.rs index e8e97b9..a01aad4 100644 --- a/src/routes/send.rs +++ b/src/routes/send.rs @@ -26,29 +26,26 @@ pub struct SendRequest { pub html_body: Option, pub in_reply_to: Option, pub references: Option, - /// If sending from a draft, include the draft ID to load attachments - /// and clean up after send. + pub draft_id: Option, - /// Optional identity ID to use as the From address. - /// If not provided, falls back to the session email. + pub from_identity_id: Option, - /// Request an MDN/read receipt at the selected identity/account address. + #[serde(default)] pub request_mdn: bool, - /// Message priority: high, normal, or low. + #[serde(default = "default_priority")] pub priority: String, - /// OpenPGP mode: none, sign, encrypt, or sign-encrypt. When omitted, - /// identity defaults are used. + pub pgp_mode: Option, - /// Override identity default for legacy inline OpenPGP. PGP/MIME is the default. + pub pgp_inline: Option, - /// Passphrase for the selected private key. Never persisted. + pub pgp_passphrase: Option, - /// Attach the sender's public OpenPGP key as application/pgp-keys. + #[serde(default)] pub pgp_attach_public_key: bool, - /// Attach public OpenPGP keys for visible To/Cc recipients. Bcc is intentionally excluded. + #[serde(default)] pub pgp_attach_recipient_keys: bool, } @@ -70,10 +67,6 @@ pub struct SendResponse { 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( Extension(session): Extension, Extension(config): Extension>, @@ -85,14 +78,12 @@ pub async fn send_message_handler( 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() { return Err(AppError::BadRequest( "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_text = req .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() { return Err(AppError::ServiceUnavailable( "SMTP server is not configured for this account".to_string(), @@ -133,7 +120,6 @@ pub async fn send_message_handler( 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) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; let selected_identity = if let Some(identity_id) = req.from_identity_id { @@ -231,8 +217,6 @@ pub async fn send_message_handler( )?; 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)?; for address in &req.to { validate_mailbox("To", address)?; @@ -250,7 +234,6 @@ pub async fn send_message_handler( 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 { load_draft_attachments(&config.data_dir, &session.user_hash, draft_id)? } else { @@ -283,7 +266,6 @@ pub async fn send_message_handler( }); } - // Build the sendable message. let message = SendableMessage { from: from_address, to: req.to, @@ -306,8 +288,6 @@ pub async fn send_message_handler( 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 .send_message(&smtp_creds, &message) .await @@ -323,8 +303,6 @@ pub async fn send_message_handler( })?; 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 { host: session.imap_host.clone(), 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 Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash) && let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id) @@ -387,7 +363,6 @@ pub async fn send_message_handler( .into_response()) } -/// Load attachment data from disk for a given draft. fn load_draft_attachments( data_dir: &str, user_hash: &str, @@ -417,16 +392,13 @@ fn load_draft_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) { - // Delete draft from DB (cascade deletes attachment records). if let Ok(conn) = db::pool::open_user_db(data_dir, user_hash) && let Err(e) = db::drafts::delete_draft(&conn, draft_id) { tracing::warn!(error = %e, "Failed to delete draft after send"); } - // Remove the attachment directory from disk. let att_dir = Path::new(data_dir) .join(user_hash) .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) .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 { mode, inline, diff --git a/src/routes/sieve.rs b/src/routes/sieve.rs index 775b4e8..3edc65d 100644 --- a/src/routes/sieve.rs +++ b/src/routes/sieve.rs @@ -98,7 +98,6 @@ async fn connect( .map_err(map_sieve_error) } -/// GET /api/sieve/status pub async fn status( Extension(session): Extension, Extension(config): Extension>, @@ -122,7 +121,6 @@ pub async fn status( .into_response()) } -/// GET /api/sieve/scripts pub async fn list_scripts( Extension(session): Extension, Extension(config): Extension>, @@ -133,7 +131,6 @@ pub async fn list_scripts( Ok(Json(ScriptsResponse { scripts }).into_response()) } -/// GET /api/sieve/scripts/:name pub async fn get_script( Path(name): Path, Extension(session): Extension, @@ -145,7 +142,6 @@ pub async fn get_script( Ok(Json(ScriptResponse { name, content }).into_response()) } -/// POST /api/sieve/check pub async fn check_script( Extension(session): Extension, Extension(config): Extension>, @@ -171,7 +167,6 @@ pub async fn check_script( Ok(Json(response).into_response()) } -/// PUT /api/sieve/scripts/:name pub async fn put_script( Path(name): Path, Extension(session): Extension, @@ -187,7 +182,6 @@ pub async fn put_script( Ok(Json(ActionResponse { ok: true, warning }).into_response()) } -/// POST /api/sieve/scripts/:name/activate pub async fn activate_script( Path(name): Path, Extension(session): Extension, @@ -206,7 +200,6 @@ pub async fn activate_script( .into_response()) } -/// POST /api/sieve/deactivate pub async fn deactivate_script( Extension(session): Extension, Extension(config): Extension>, @@ -224,7 +217,6 @@ pub async fn deactivate_script( .into_response()) } -/// DELETE /api/sieve/scripts/:name pub async fn delete_script( Path(name): Path, Extension(session): Extension, @@ -240,7 +232,6 @@ pub async fn delete_script( .into_response()) } -/// POST /api/sieve/scripts/:name/rename pub async fn rename_script( Path(name): Path, Extension(session): Extension, diff --git a/src/routes/tags.rs b/src/routes/tags.rs index 4acd39c..0da18dc 100644 --- a/src/routes/tags.rs +++ b/src/routes/tags.rs @@ -14,10 +14,6 @@ use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; use crate::routes::messages::types::{MessageSummary, default_per_page}; -// --------------------------------------------------------------------------- -// Request / response types -// --------------------------------------------------------------------------- - #[derive(Deserialize)] pub struct CreateTagBody { pub name: String, @@ -82,11 +78,6 @@ pub struct ReconcileTagsResponse { pub skipped_folders: usize, } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Build a `MessageSummary` from a cached message and its tags. fn build_summary(m: db::messages::CachedMessage, tags: Vec) -> MessageSummary { MessageSummary { 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( Extension(session): Extension, Extension(config): Extension>, @@ -185,7 +171,6 @@ pub async fn list_tags_handler( Ok(Json(ListTagsResponse { tags }).into_response()) } -/// `POST /api/tags` pub async fn create_tag_handler( Extension(session): Extension, Extension(config): Extension>, @@ -209,7 +194,6 @@ pub async fn create_tag_handler( Ok(Json(serde_json::json!({ "id": id, "name": name, "color": color })).into_response()) } -/// `PUT /api/tags/{id}` pub async fn update_tag_handler( Extension(session): Extension, Extension(config): Extension>, @@ -238,15 +222,12 @@ pub async fn update_tag_handler( } } -/// `DELETE /api/tags/{id}` pub async fn delete_tag_handler( Extension(session): Extension, Extension(config): Extension>, Extension(imap_client): Extension>, Path(id): Path, ) -> Result { - // 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 conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; @@ -261,7 +242,6 @@ pub async fn delete_tag_handler( (tag, refs) }; - // Phase 2: best-effort remote cleanup while the tag still exists locally. let mut synced = 0usize; for (uid, folder) in &refs { 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 conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; @@ -297,7 +276,6 @@ pub async fn delete_tag_handler( .into_response()) } -/// `POST /api/tags/{id}/messages` pub async fn tag_message_handler( Extension(session): Extension, Extension(config): Extension>, @@ -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()) } -/// `DELETE /api/tags/{id}/messages/{folder}/{uid}` pub async fn untag_message_handler( Extension(session): Extension, Extension(config): Extension>, @@ -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()) } -/// `POST /api/tags/{id}/messages/bulk` pub async fn bulk_tag_handler( Extension(session): Extension, Extension(config): Extension>, @@ -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()) } -/// `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( Extension(session): Extension, Extension(config): Extension>, @@ -521,7 +492,6 @@ pub async fn reconcile_tags_handler( .into_response()) } -/// `GET /api/tags/{id}/messages` pub async fn list_tag_messages_handler( Extension(session): Extension, Extension(config): Extension>, @@ -536,7 +506,6 @@ pub async fn list_tag_messages_handler( let total_count = db::tags::count_messages_by_tag(&conn, &id) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; - // Batch-fetch tags for returned messages let message_refs: Vec<(u32, &str)> = messages .iter() .map(|m| (m.uid, m.folder.as_str())) @@ -563,7 +532,6 @@ pub async fn list_tag_messages_handler( .into_response()) } -/// `GET /api/messages/{folder}/{uid}/tags` pub async fn get_message_tags_handler( Extension(session): Extension, Extension(config): Extension>, diff --git a/src/routes/tests.rs b/src/routes/tests.rs index 26897a6..220132d 100644 --- a/src/routes/tests.rs +++ b/src/routes/tests.rs @@ -17,7 +17,6 @@ use crate::imap::client::{ use crate::smtp::client::SmtpError; use crate::smtp::client::mock::MockSmtpClient; -/// Helper: create a test AppConfig with the given static dir. fn test_config(_static_dir: &str) -> Arc { Arc::new(AppConfig { host: "127.0.0.1".to_string(), @@ -42,7 +41,6 @@ fn test_config(_static_dir: &str) -> Arc { }) } -/// 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 { Arc::new(AppConfig { host: "127.0.0.1".to_string(), @@ -67,7 +65,6 @@ fn test_config_with_imap(_static_dir: &str, data_dir: &str) -> Arc { }) } -/// Helper: create a test AppConfig with IMAP + SMTP hosts configured. fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc { Arc::new(AppConfig { host: "127.0.0.1".to_string(), @@ -92,22 +89,18 @@ fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc { }) } -/// Helper: create a fresh SessionStore for tests. fn test_store() -> Arc { Arc::new(SessionStore::new(Duration::from_secs(3600))) } -/// Helper: create a default mock IMAP client. fn test_imap_client() -> Arc { Arc::new(MockImapClient::new()) } -/// Helper: create a default mock SMTP client. fn test_smtp_client() -> Arc { Arc::new(MockSmtpClient::new()) } -/// Helper: create a temporary static directory with an index.html. fn setup_static_dir() -> TempDir { let dir = TempDir::new().expect("should create temp dir"); fs::write( @@ -118,19 +111,16 @@ fn setup_static_dir() -> TempDir { dir } -/// Helper: create a test SearchEngine backed by the given data directory. fn test_search_engine(data_dir: &str) -> Arc { Arc::new(crate::search::engine::SearchEngine::new( std::path::PathBuf::from(data_dir), )) } -/// Helper: create a test EventBus. fn test_event_bus() -> Arc { Arc::new(crate::realtime::events::EventBus::new()) } -/// Helper: create a test IdleManager. fn test_idle_manager() -> Arc { Arc::new(crate::realtime::idle::IdleManager::new()) } @@ -159,8 +149,6 @@ fn extract_ip(extractor: &ProxyAwareIpExtractor, peer: &str, forwarded: Option<& 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) { let browser_id = store.create_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) } -/// Helper: build auth headers for multi-account requests. fn auth_headers(browser_id: &str, account_id: &str, token: &str) -> Vec<(&'static str, String)> { 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) { let _conn = crate::db::pool::open_user_db(data_dir, user_hash).unwrap(); } -// ----------------------------------------------------------------------- -// Proxy-aware IP extraction tests -// ----------------------------------------------------------------------- - #[test] fn trusted_ipv4_proxy_uses_forwarded_client_ip() { 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::().unwrap()); } -// ----------------------------------------------------------------------- -// Existing tests (updated to pass imap_client) -// ----------------------------------------------------------------------- - #[tokio::test] async fn api_health_works_with_static_fallback() { let dir = setup_static_dir(); @@ -536,7 +513,7 @@ async fn login_unreachable_imap_returns_503() { let dir = setup_static_dir(); let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone(); 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; let config = Arc::new(cfg); 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(); assert_eq!(json["status"], "logged_out"); - // Session should be removed from the store. 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(), ); - // This route is outside /api/auth, where browsers default empty cookie paths. let mut request = Request::builder().uri(format!("{prefix}/api/folders")); for (name, value) in auth_headers(&browser_id, &account_id, &token) { request = request.header(name, value); @@ -795,15 +770,11 @@ async fn logout_clears_cookie() { assert_eq!(response.status(), StatusCode::OK); 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); assert!(cookie_str.contains("rustbox_browser=;") || cookie_str.contains("Max-Age=0")); } -// ----------------------------------------------------------------------- -// New tests for folders and messages routes -// ----------------------------------------------------------------------- - #[tokio::test] async fn get_folders_returns_200_with_folder_list() { let static_dir = setup_static_dir(); @@ -815,7 +786,6 @@ async fn get_folders_returns_200_with_folder_list() { let store = test_store(); let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com"); - // Provision user database. provision_user_db( data_dir.path().to_str().unwrap(), &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"), ); - // First, we need the message header in cache (fetch_headers first). let mock = MockImapClient::new() .with_headers(vec![ImapMessageHeader { uid: 42, @@ -1079,7 +1048,6 @@ async fn get_message_returns_sanitized_html() { test_idle_manager(), ); - // First, populate the message cache by listing messages. let mut req1 = Request::builder() .uri("/api/folders/INBOX/messages") .header("x-requested-with", "XMLHttpRequest"); @@ -1092,7 +1060,6 @@ async fn get_message_returns_sanitized_html() { .unwrap(); assert_eq!(response.status(), StatusCode::OK); - // Now get the full message. let app2 = create_router( config, store, @@ -1121,19 +1088,15 @@ async fn get_message_returns_sanitized_html() { assert_eq!(json["uid"], 42); assert_eq!(json["subject"], "Test Subject"); - // Script tag is preserved (frontend handles security via iframe sandbox). let html = json["html"].as_str().unwrap(); assert!(html.contains("script")); assert!(html.contains("bold")); assert!(html.contains("

Hello

")); - // Plain text should be preserved. assert_eq!(json["text"], "Hello plain text"); - // Flags should be an array. assert!(json["flags"].is_array()); - // to_addresses should be an 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"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); - // Seed a message in the cache. let conn = 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(); @@ -1210,9 +1172,6 @@ async fn update_flags_returns_200() { #[tokio::test] 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 data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( @@ -1251,8 +1210,7 @@ async fn update_flags_rejects_invalid_flag_characters() { .unwrap(); 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 json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 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"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); - // Seed a message in the cache. let conn = 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(); @@ -1485,7 +1442,6 @@ async fn delete_message_returns_200() { let user_hash = crate::auth::user_data::hash_email("alice@example.com"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); - // Seed a message in the cache. let conn = 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(); @@ -1535,10 +1491,6 @@ async fn delete_message_returns_200() { assert_eq!(response.status(), StatusCode::OK); } -// ----------------------------------------------------------------------- -// Attachment download tests -// ----------------------------------------------------------------------- - #[tokio::test] async fn download_attachment_returns_binary_with_correct_headers() { 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); - // Verify content-type header. let ct = response .headers() .get("content-type") @@ -1597,7 +1548,6 @@ async fn download_attachment_returns_binary_with_correct_headers() { .unwrap(); assert_eq!(ct, "application/pdf"); - // Verify content-disposition header. let cd = response .headers() .get("content-disposition") @@ -1607,7 +1557,6 @@ async fn download_attachment_returns_binary_with_correct_headers() { assert!(cd.contains("inline")); assert!(cd.contains("document.pdf")); - // Verify body bytes match the attachment data. let body = response.into_body().collect().await.unwrap().to_bytes(); 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"), ("\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 data_dir = TempDir::new().unwrap(); let config = test_config_with_imap( @@ -1672,7 +1618,7 @@ async fn download_attachment_strips_control_chars_from_filename() { .unwrap() .to_str() .unwrap(); - // Control characters stripped; the rest of the filename is intact. + assert!(cd.contains(expected_filename), "got: {cd}"); assert!(!cd.contains('\r')); 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); } -// ----------------------------------------------------------------------- -// Send message endpoint tests -// ----------------------------------------------------------------------- - #[tokio::test] async fn send_returns_200_on_success() { let static_dir = setup_static_dir(); @@ -2150,7 +2092,7 @@ async fn quota_without_server_limit_uses_cached_sizes() { ) .unwrap(); drop(conn); - // The mock's IMAP size fallback returns zero; only SQLite has 4096. + let app = create_router( config, store, @@ -2178,8 +2120,6 @@ async fn export_contact_strips_control_chars_from_filename() { ("Bob\nSmith", "BobSmith.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 data_dir = TempDir::new().unwrap(); 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"); provision_user_db(data_dir.path().to_str().unwrap(), &user_hash); - // Insert a contact whose name contains a newline. let conn = crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap(); let contact = crate::db::contacts::Contact { @@ -2246,7 +2185,7 @@ async fn export_contact_strips_control_chars_from_filename() { .unwrap() .to_str() .unwrap(); - // Control character stripped; the rest of the name is intact. + assert!(cd.contains(expected_filename), "got: {cd}"); assert!(!cd.contains('\n')); } diff --git a/src/routes/unsubscribe.rs b/src/routes/unsubscribe.rs index 37fcb62..75008c2 100644 --- a/src/routes/unsubscribe.rs +++ b/src/routes/unsubscribe.rs @@ -20,8 +20,6 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const MAX_RESPONSE_BYTES: usize = 8192; 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( Extension(session): Extension, Extension(config): Extension>, @@ -230,7 +228,7 @@ async fn post_one_click_to_address( format!("{host}:{port}") }; 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() ); diff --git a/src/search/engine.rs b/src/search/engine.rs index cef53f8..e2f8a93 100644 --- a/src/search/engine.rs +++ b/src/search/engine.rs @@ -11,7 +11,6 @@ use tantivy::schema::{ use tantivy::snippet::SnippetGenerator; use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc}; -/// A message ready to be indexed into the search engine. pub struct IndexableMessage { pub uid: u32, pub folder: String, @@ -24,7 +23,6 @@ pub struct IndexableMessage { pub has_attachments: bool, } -/// A single search result returned from a query. #[derive(Debug, Serialize)] pub struct SearchResult { pub uid: u32, @@ -33,7 +31,6 @@ pub struct SearchResult { pub snippet: String, } -/// Parameters for a search query, with optional filters. #[derive(Default)] pub struct SearchQuery { pub text: String, @@ -48,12 +45,10 @@ pub struct SearchQuery { pub offset: usize, } -/// Top-level search engine that manages per-user indices. pub struct SearchEngine { base_dir: PathBuf, } -/// Named field handles for convenient access within the schema. struct SchemaFields { uid: Field, folder: Field, @@ -66,7 +61,6 @@ struct SchemaFields { has_attachments: Field, } -/// A per-user Tantivy index with reader and schema. pub struct UserIndex { index: Index, reader: IndexReader, @@ -75,7 +69,6 @@ pub struct UserIndex { fields: SchemaFields, } -/// Build the shared schema used by all user indices. fn build_schema() -> (Schema, SchemaFields) { let mut builder = Schema::builder(); @@ -114,12 +107,10 @@ fn build_schema() -> (Schema, SchemaFields) { } impl SearchEngine { - /// Create a new search engine with the given base directory. pub fn new(base_dir: PathBuf) -> Self { Self { base_dir } } - /// Open or create the Tantivy index for a specific user. pub fn open_user_index(&self, user_hash: &str) -> Result { let index_dir = self.base_dir.join(user_hash).join("tantivy"); std::fs::create_dir_all(&index_dir) @@ -151,8 +142,6 @@ impl SearchEngine { } 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 { let lower = folder.to_lowercase(); lower == "trash" @@ -163,7 +152,6 @@ impl UserIndex { || lower.ends_with("/spam") } - /// Index a single message. Uses delete-before-insert for upsert semantics. pub fn index_message(&self, msg: &IndexableMessage) -> Result<(), String> { let mut writer: IndexWriter = self .index @@ -188,14 +176,10 @@ impl UserIndex { Ok(()) } - /// Return the number of indexed documents currently visible to the reader. pub fn document_count(&self) -> 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 { let mut writer: IndexWriter = self .index @@ -225,7 +209,6 @@ impl UserIndex { 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> { let mut writer: IndexWriter = self .index @@ -251,7 +234,6 @@ impl UserIndex { Ok(()) } - /// Delete a message from the index by uid and folder. #[allow(dead_code)] pub fn delete_message(&self, uid: u32, folder: &str) -> Result<(), String> { let mut writer: IndexWriter = self @@ -272,15 +254,11 @@ impl UserIndex { 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, usize), String> { let searcher = self.reader.searcher(); - // Collect all sub-queries into a BooleanQuery with Must clauses. let mut clauses: Vec<(Occur, Box)> = Vec::new(); - // Build the full-text query across subject, body_text, from_name, to_addresses. if !query.text.is_empty() { let query_parser = QueryParser::for_index( &self.index, @@ -299,7 +277,6 @@ impl UserIndex { clauses.push((Occur::Must, text_query)); } - // Optional subject-only search (searches only the subject field). if let Some(ref subject_text) = query.subject_only { let subject_parser = QueryParser::for_index(&self.index, vec![self.fields.subject]); let subject_query = subject_parser @@ -308,20 +285,16 @@ impl UserIndex { 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() { clauses.push((Occur::Must, Box::new(tantivy::query::AllQuery))); } - // Optional folder filter (STRING field -> TermQuery). if let Some(ref folder) = query.folder { let term = Term::from_field_text(self.fields.folder, folder); let folder_query = TermQuery::new(term, IndexRecordOption::Basic); 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 from.contains('@') { 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 { let to_parser = QueryParser::for_index(&self.index, vec![self.fields.to_addresses]); let to_query = to_parser @@ -345,7 +317,6 @@ impl UserIndex { clauses.push((Occur::Must, to_query)); } - // Optional date range filter. if query.date_from.is_some() || query.date_to.is_some() { let lower = match query.date_from { Some(ts) => Bound::Included(ts), @@ -359,7 +330,6 @@ impl UserIndex { clauses.push((Occur::Must, Box::new(range_query))); } - // Optional has_attachment filter. if let Some(has_att) = query.has_attachment { let val = if has_att { 1u64 } else { 0u64 }; let term = Term::from_field_u64(self.fields.has_attachments, val); @@ -369,13 +339,11 @@ impl UserIndex { let combined_query = BooleanQuery::new(clauses); - // Get total count and paginated results. let total_limit = query.offset + query.limit; let (total_count, top_docs) = searcher .search(&combined_query, &(Count, TopDocs::with_limit(total_limit))) .map_err(|e| format!("Search failed: {e}"))?; - // Build a snippet generator for the subject field. let mut snippet_generator = SnippetGenerator::create(&searcher, &combined_query, self.fields.subject) .map_err(|e| format!("Failed to create snippet generator: {e}"))?; @@ -416,7 +384,6 @@ impl UserIndex { Ok((results, total_count)) } - /// Delete existing documents that match the uid+folder combination. 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 folder_term = Term::from_field_text(self.fields.folder, folder); @@ -432,7 +399,6 @@ impl UserIndex { let _ = writer.delete_query(Box::new(delete_query)); } - /// Build a Tantivy document from an IndexableMessage. fn build_document(&self, msg: &IndexableMessage) -> TantivyDocument { doc!( self.fields.uid => msg.uid as u64, @@ -453,7 +419,6 @@ mod tests { use super::*; use tempfile::TempDir; - /// Helper: create a SearchEngine + UserIndex in a temp directory. fn setup() -> (TempDir, UserIndex) { let tmp = TempDir::new().expect("Failed to create temp dir"); let engine = SearchEngine::new(tmp.path().to_path_buf()); @@ -535,7 +500,6 @@ mod tests { idx.index_messages_batch(&[msg1, msg2]).unwrap(); - // Search with folder filter for "Sent" only. let query = SearchQuery { text: "project".to_string(), folder: Some("Sent".to_string()), @@ -555,21 +519,20 @@ mod tests { let (_tmp, idx) = setup(); 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."); - msg2.date_epoch = 1709251200; // 2024-03-01 + msg2.date_epoch = 1709251200; 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(); - // Search for "news" within February to April. let query = SearchQuery { text: "news".to_string(), - date_from: Some(1706745600), // 2024-02-01 - date_to: Some(1714521600), // 2024-05-01 + date_from: Some(1706745600), + date_to: Some(1714521600), limit: 10, ..Default::default() }; @@ -589,7 +552,6 @@ mod tests { idx.index_messages_batch(&[msg1, msg2]).unwrap(); - // Verify both are found. let query = SearchQuery { text: "important".to_string(), limit: 10, @@ -598,7 +560,6 @@ mod tests { let (_, total) = idx.search(&query).unwrap(); assert_eq!(total, 2); - // Delete message 1. idx.delete_message(1, "INBOX").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."); idx.index_message(&msg).unwrap(); - // Empty text with no filters uses AllQuery — returns everything. let query = SearchQuery { text: "".to_string(), limit: 10, @@ -643,7 +603,6 @@ mod tests { idx.index_messages_batch(&messages).unwrap(); - // Page 1: limit=2, offset=0. let query_page1 = SearchQuery { text: "report".to_string(), limit: 2, @@ -654,7 +613,6 @@ mod tests { assert_eq!(total1, 5); assert_eq!(results1.len(), 2); - // Page 2: limit=2, offset=2. let query_page2 = SearchQuery { text: "report".to_string(), limit: 2, @@ -665,7 +623,6 @@ mod tests { assert_eq!(total2, 5); assert_eq!(results2.len(), 2); - // Results from page 1 and page 2 should be different. let uids1: Vec = results1.iter().map(|r| r.uid).collect(); let uids2: Vec = results2.iter().map(|r| r.uid).collect(); for uid in &uids2 { diff --git a/src/sieve/client.rs b/src/sieve/client.rs index 11c2d89..3e367a2 100644 --- a/src/sieve/client.rs +++ b/src/sieve/client.rs @@ -19,7 +19,7 @@ pub const MAX_SCRIPT_BYTES: usize = 1024 * 1024; pub struct SieveCredentials { pub host: String, pub port: u16, - /// `starttls`, `implicit`, or `none`. + pub tls_mode: String, pub allow_insecure_auth: bool, pub username: String, @@ -84,16 +84,12 @@ impl ManageSieveClient { let reply = read_response(&mut tcp, creds.timeout).await?; 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 mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp)) .await .map_err(|_| SieveError::Timeout)? .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?; (SieveStream::Tls(tls_stream), greeting) } @@ -212,7 +208,6 @@ impl ManageSieveClient { 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 response = self.command(&have_space).await?; ensure_ok(&response)?; @@ -362,7 +357,7 @@ impl ProtocolResponse { } 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('"') { let prefix = &text[..pos]; if let Some(start) = prefix.rfind('"') { @@ -436,8 +431,6 @@ async fn read_wire_line(stream: &mut S) -> Result Result, SieveError> { return Ok(None); } let raw = &trimmed[open + 1..trimmed.len() - 1]; - // Server-to-client literals do not use the non-synchronizing + form. + if raw.ends_with('+') { return Err(SieveError::Protocol( "server sent an invalid non-synchronizing literal".into(), diff --git a/src/sieve/error.rs b/src/sieve/error.rs index 4c41633..b43ccb0 100644 --- a/src/sieve/error.rs +++ b/src/sieve/error.rs @@ -29,7 +29,7 @@ impl fmt::Display for SieveError { write!(f, "ManageSieve server rejected the command: {msg}") } 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"), } } } diff --git a/src/smtp/builder.rs b/src/smtp/builder.rs index 355a88c..24d5e74 100644 --- a/src/smtp/builder.rs +++ b/src/smtp/builder.rs @@ -1,5 +1,3 @@ -//! Shared RFC822/MIME builder used by SMTP delivery and IMAP Sent copies. - use lettre::message::{ Attachment, Mailbox, MessageBuilder, MultiPart, SinglePart, header::ContentType, }; diff --git a/src/smtp/client.rs b/src/smtp/client.rs index 6611cfa..ced71eb 100644 --- a/src/smtp/client.rs +++ b/src/smtp/client.rs @@ -1,46 +1,22 @@ -//! SMTP client abstraction and real implementation. - use async_trait::async_trait; -// Import and re-export types for backward compatibility pub use crate::smtp::error::SmtpError; 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)] #[async_trait] pub trait SmtpClient: Send + Sync { - /// Send an email message. Returns the generated Message-ID and exact RFC822 bytes. async fn send_message( &self, creds: &SmtpCredentials, message: &SendableMessage, ) -> Result; - /// Verify that the configured SMTP endpoint accepts an authenticated NOOP. async fn check_connection(&self, _creds: &SmtpCredentials) -> Result<(), SmtpError> { 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; fn smtp_transport( @@ -142,10 +118,6 @@ impl SmtpClient for RealSmtpClient { } } -// --------------------------------------------------------------------------- -// Mock implementation (test-only) -// --------------------------------------------------------------------------- - #[cfg(test)] #[path = "mock.rs"] pub mod mock; diff --git a/src/smtp/error.rs b/src/smtp/error.rs index 178a383..5cccc65 100644 --- a/src/smtp/error.rs +++ b/src/smtp/error.rs @@ -1,18 +1,14 @@ -//! SMTP error types. - use std::fmt; -/// Errors that can occur during SMTP operations. #[derive(Debug)] #[allow(clippy::enum_variant_names)] pub enum SmtpError { - /// Could not connect to the SMTP server. ConnectionFailed(String), - /// The server rejected our credentials. + AuthenticationFailed, - /// The message could not be sent. + SendFailed(String), - /// The fully rendered RFC822/MIME payload exceeds the configured MTA limit. + MessageTooLarge { actual: usize, limit: usize }, } diff --git a/src/smtp/mock.rs b/src/smtp/mock.rs index cf3e4bd..7e0e1ac 100644 --- a/src/smtp/mock.rs +++ b/src/smtp/mock.rs @@ -1,23 +1,15 @@ -//! Mock SMTP client for testing. - use super::{ AttachmentData, SendableMessage, SmtpClient, SmtpCredentials, SmtpError, SmtpSendResult, }; use async_trait::async_trait; 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 { should_fail: Mutex>, sent_messages: Mutex>, } impl MockSmtpClient { - /// Create a new empty mock. pub fn new() -> Self { Self { 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 { *self.should_fail.lock().unwrap() = Some(error); self } - /// Return the number of messages sent through this mock. pub fn sent_count(&self) -> usize { self.sent_messages.lock().unwrap().len() } - /// Return a clone of the most recently sent message, if any. pub fn last_sent(&self) -> Option { 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 { match err { SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()), @@ -86,15 +73,10 @@ impl SmtpClient for MockSmtpClient { } } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; - /// Convenience helper to build dummy credentials for tests. fn test_creds() -> SmtpCredentials { SmtpCredentials { host: "smtp.example.com".to_string(), @@ -106,7 +88,6 @@ mod tests { } } - /// Build a minimal test message. fn test_message() -> SendableMessage { SendableMessage { from: "user@example.com".to_string(), @@ -215,7 +196,6 @@ mod tests { .unwrap_err(); assert!(matches!(err, SmtpError::AuthenticationFailed)); - // Ensure no messages were recorded. assert_eq!(mock.sent_count(), 0); } @@ -235,7 +215,7 @@ mod tests { let msg = test_message(); let err = client.send_message(&creds, &msg).await.unwrap_err(); - // With a fake host the connection should fail. + assert!( matches!( err, diff --git a/src/smtp/types.rs b/src/smtp/types.rs index ff53794..9a43299 100644 --- a/src/smtp/types.rs +++ b/src/smtp/types.rs @@ -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)] pub struct SmtpCredentials { pub host: String, @@ -18,42 +14,40 @@ pub struct SmtpSendResult { pub rfc822: Vec, } -/// A message ready to be sent via SMTP. #[derive(Clone)] pub struct SendableMessage { - /// Sender email address. pub from: String, - /// Primary recipients. + pub to: Vec, - /// CC recipients. + pub cc: Vec, - /// BCC recipients. + pub bcc: Vec, - /// Optional Reply-To mailbox. + pub reply_to: Option, - /// Optional organization label from the selected identity. + pub organization: Option, - /// Subject line. + pub subject: String, - /// Plain-text body. + pub text_body: String, - /// Optional HTML body. + pub html_body: Option, - /// In-Reply-To header value for threading. + pub in_reply_to: Option, - /// References header value for threading. + pub references: Option, - /// Address that should receive an MDN/read receipt request. + pub read_receipt_to: Option, - /// Message priority: `high`, `normal`, or `low`. + pub priority: String, - /// Administrator-configured headers applied to every outgoing copy. + pub extra_headers: Vec<(String, String)>, - /// File attachments. + pub attachments: Vec, - /// Optional OpenPGP transformation applied to the MIME entity. + pub pgp: Option, - /// Hard limit for the final RFC822/MIME payload submitted to SMTP. + pub max_message_size_bytes: usize, } @@ -74,24 +68,17 @@ pub struct PgpSendConfig { pub recipient_public_keys: Vec, } -/// A single file attachment to include in an outgoing message. #[derive(Debug, Clone)] pub struct AttachmentData { - /// Filename as it should appear to the recipient. pub filename: String, - /// MIME content type (e.g. "application/pdf"). + pub content_type: String, - /// Raw file content. + pub data: Vec, - /// 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, } -/// 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( builder: lettre::message::MessageBuilder, message: &SendableMessage, @@ -120,8 +107,6 @@ pub fn apply_optional_headers( 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( mut builder: lettre::message::MessageBuilder, read_receipt_to: Option<&str>, diff --git a/src/ui.rs b/src/ui.rs index 1bfcc46..5c728ca 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -64,7 +64,6 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension>) -> } 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.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS), "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>) -> .replace("__RUSTBOX_ASSET_ID__", ASSET_ID) .replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION")) .replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme) - .replace("__RUSTBOX_DEFAULT_INTERFACE_SKIN__", &config.default_interface_skin) - .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_DEFAULT_INTERFACE_SKIN__", + &config.default_interface_skin, + ) + .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()); ( StatusCode::OK, diff --git a/src/web/css/00-tokens.css b/src/web/css/00-tokens.css index 3931582..985d4ec 100644 --- a/src/web/css/00-tokens.css +++ b/src/web/css/00-tokens.css @@ -194,7 +194,7 @@ html[data-color-preset="red"] { --selected: color-mix(in srgb, var(--accent) 20%, var(--panel)); } -/* Advanced per-user interface controls. */ + :root { --toolbar-density-factor: 1; --sidebar-item-height: 29px; diff --git a/src/web/css/30-mail.css b/src/web/css/30-mail.css index df837d1..3d35d98 100644 --- a/src/web/css/30-mail.css +++ b/src/web/css/30-mail.css @@ -754,7 +754,7 @@ min-width: 0; } -/* Reader toolbars must remain usable at every UI scale. */ + .reader-toolbar { max-width: 100%; overflow-x: auto; @@ -834,7 +834,7 @@ } } -/* Hierarchical folder tree. The disclosure control is separate from the folder action. */ + .folder-row { display: grid; grid-template-columns: 18px minmax(0, 1fr); @@ -967,7 +967,7 @@ html[data-reduce-motion="true"] #thread-toggle svg { color: var(--text); } -/* A flagged message stays visually quiet while remaining easy to scan. */ + .message-row.flagged { 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; } -/* Advanced appearance preferences. */ + .folder-pane { 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) { .reader-toolbar { gap: 1px; @@ -1193,4 +1193,108 @@ html[data-toolbar-density="spacious"] .reader-toolbar { #message-transfer-menu .toolbar-menu-popover { left: auto; 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; + } } \ No newline at end of file diff --git a/src/web/css/40-compose.css b/src/web/css/40-compose.css index 4b804ae..79aacc2 100644 --- a/src/web/css/40-compose.css +++ b/src/web/css/40-compose.css @@ -611,7 +611,7 @@ min-height: 1.35em; } -/* OpenPGP composer / reader */ + .pgp-reader-bar { display: flex; align-items: center; diff --git a/src/web/css/50-settings.css b/src/web/css/50-settings.css index 5805e32..855e761 100644 --- a/src/web/css/50-settings.css +++ b/src/web/css/50-settings.css @@ -1824,40 +1824,6 @@ 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) { @@ -1959,7 +1925,7 @@ } } -/* Dialog/layout hardening */ + .contacts-card, .calendar-card, .headers-card, diff --git a/src/web/css/55-roundcube-skins.css b/src/web/css/55-roundcube-skins.css index 315863b..7f535ae 100644 --- a/src/web/css/55-roundcube-skins.css +++ b/src/web/css/55-roundcube-skins.css @@ -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"] { --font-sans: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif; --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-snippet { color: #ffffff; - border-color: rgba(255,255,255,.8); + border-color: rgba(255, 255, 255, .8); } html[data-interface-skin="roundcube-classic"] .settings-tab { @@ -212,7 +207,7 @@ html[data-interface-skin="roundcube-classic"] .diagnostic-card { border-radius: 0; } -/* ---------- Roundcube Elastic ---------- */ + html[data-interface-skin="roundcube-elastic"] { --font-sans: Roboto, "Segoe UI", Arial, sans-serif; --radius-control: .3rem; @@ -369,7 +364,7 @@ html[data-interface-skin="roundcube-elastic"] .appearance-preset:hover { border-color: #37beff; } -/* Mobile drawer follows the active skin and uses Roundcube Elastic's back affordance. */ + @media (max-width: 820px) { .folder-drawer-header.mobile-only { 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 { background: #2f3a3f; } -} +} \ No newline at end of file diff --git a/src/web/css/56-interface-styles.css b/src/web/css/56-interface-styles.css index 8ad263d..0e5c72e 100644 --- a/src/web/css/56-interface-styles.css +++ b/src/web/css/56-interface-styles.css @@ -1,4 +1,3 @@ -/* ---------- Roundcube Larry ---------- */ html[data-interface-skin="roundcube-larry"] { --font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif; --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); } -/* ---------- Clean ---------- */ + html[data-interface-skin="clean"][data-theme="light"] { color-scheme: light; --bg: #f5f7fa; @@ -177,7 +176,7 @@ html[data-interface-skin="clean"] .button.primary { color: #ffffff; } -/* ---------- Graphite ---------- */ + html[data-interface-skin="graphite"][data-theme="light"] { color-scheme: light; --bg: #e4e6e8; @@ -254,7 +253,7 @@ html[data-interface-skin="graphite"] .button.primary { color: #ffffff; } -/* ---------- High contrast ---------- */ + html[data-interface-skin="high-contrast"] { --radius-control: 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="proton"], html[data-interface-skin="zoho"], @@ -671,7 +670,7 @@ html[data-interface-skin="minimal"] .button.primary { color: var(--accent-ink); } -/* ---------- Additional predefined mail styles ---------- */ + html[data-interface-skin="fastmail"], html[data-interface-skin="apple-mail"], html[data-interface-skin="yahoo"], diff --git a/src/web/css/60-responsive.css b/src/web/css/60-responsive.css index 2e301ea..a00168d 100644 --- a/src/web/css/60-responsive.css +++ b/src/web/css/60-responsive.css @@ -1,4 +1,3 @@ -/* Dedicated full-screen composer mode. */ .compose-dialog.compose-fullscreen { width: 100vw; height: 100dvh; @@ -271,7 +270,7 @@ html[data-preview="bottom"] .reader-pane { } } -/* 0.0.3 mobile reliability pass */ + .folder-quota { display: grid; gap: 5px; @@ -326,7 +325,7 @@ html[data-preview="bottom"] .reader-pane { background: var(--danger); } -/* The visual Sieve builder must never let checkbox inputs inherit width:100%. */ + .sieve-builder .checkbox-row input[type="checkbox"] { width: 15px; min-width: 15px; @@ -543,22 +542,19 @@ html[data-preview="bottom"] .reader-pane { @media (max-width: 560px) { .reader-toolbar .toolbar-menu>summary span { - display: none; + display: inline; } .reader-toolbar .toolbar-menu>summary { - min-width: 30px; + min-width: max-content; + min-height: 34px; padding-inline: 7px; } .reader-toolbar .toolbar-menu-popover { position: fixed; - top: auto; - right: 8px; - bottom: 8px; - left: 8px; - min-width: 0; - max-width: none; + min-width: 180px; + max-width: calc(100vw - 16px); } .compose-footer { diff --git a/src/web/css/components.css b/src/web/css/components.css index d42a0db..17ef52f 100644 --- a/src/web/css/components.css +++ b/src/web/css/components.css @@ -1,4 +1,3 @@ -/* OpenPGP */ .pgp-reader-bar { display: flex; align-items: center; diff --git a/src/web/index.html b/src/web/index.html index caa45bc..a97f0ae 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -1,5 +1,10 @@ - + @@ -205,6 +210,10 @@ aria-label="Next message"> + @@ -270,7 +279,8 @@ aria-label="Add sender to contacts"> - + @@ -449,9 +459,12 @@ - ~0 / __RUSTBOX_MAX_MESSAGE_MB__ MiB -
@@ -595,7 +608,10 @@ RustBox is fully customizable: colors, spacing, panes and typography. - + - - + +

Markdown is stored as source text and sent as a safe rendered HTML alternative with a plain-text fallback.

@@ -1073,10 +1094,12 @@
-
IMAPNot checked
+
IMAPNot checked +
-
+
SMTPNot checked @@ -1139,7 +1162,8 @@
Author
@linuxiarz.pl
Repository
-
git.linuxiarz.pl/gru/RustBox
+
git.linuxiarz.pl/gru/RustBox
diff --git a/src/web/js/00-bootstrap.js b/src/web/js/00-bootstrap.js index ce8ba47..9c70b94 100644 --- a/src/web/js/00-bootstrap.js +++ b/src/web/js/00-bootstrap.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 00-bootstrap.js */ + (() => { "use strict"; @@ -50,13 +50,23 @@ markReadTimer: null, ws: null, wsRetryTimer: null, + wsRetryAttempt: 0, + wsWatchdogTimer: null, + wsLastHeartbeatAt: 0, + wsShouldConnect: false, + wsConnectedOnce: false, realtimeRefreshTimer: null, + realtimeRefreshEvents: [], + realtimePollTimer: null, + realtimeSyncPromise: null, + realtimeSeenEvents: new Map(), calendarReminderTimer: null, composeDraftId: null, composeAttachments: [], composeDirty: false, composeNeedsRemoteSync: false, composeUploading: false, + composeUploadController: null, composeReplyMeta: null, composeInsertRange: null, composeSelectedImage: null, @@ -79,6 +89,8 @@ quotaWarningLevel: null, networkOnline: navigator.onLine, listStateTimer: null, + readerFullscreenHistory: false, + readerScrollTopBeforeFullscreen: 0, }; const $ = (selector, parent = document) => parent.querySelector(selector); @@ -105,8 +117,8 @@ function stripRustBoxUiLeak(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(/]*>\s*]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, ""); text = text.replace(/<svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?<\/svg>/gi, ""); return text; diff --git a/src/web/js/10-core.js b/src/web/js/10-core.js index 18f601e..89f8224 100644 --- a/src/web/js/10-core.js +++ b/src/web/js/10-core.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 10-core.js */ + function setConnectivityState(online, detail = "") { state.networkOnline = Boolean(online); document.body.classList.toggle("network-offline", !state.networkOnline); @@ -124,9 +124,17 @@ async function request(path, options = {}) { throw error; } -function uploadRequest(path, formData, onProgress = null) { +function uploadRequest(path, formData, onProgress = null, signal = null) { return new Promise((resolve, reject) => { 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.withCredentials = true; 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)); }); } - xhr.addEventListener("error", () => reject(new Error("Upload failed: network error"))); - xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); + xhr.addEventListener("error", () => finish(reject, new Error("Upload failed: network error"))); + xhr.addEventListener("abort", () => finish(reject, new DOMException("Upload cancelled", "AbortError"))); xhr.addEventListener("load", () => { const contentType = xhr.getResponseHeader("content-type") || ""; 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 error = new Error(message); error.status = xhr.status; - reject(error); + finish(reject, error); 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); }); } @@ -251,72 +264,6 @@ function toast(message, kind = "info", duration = null, action = null) { 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 } = {}) { return new Promise((resolve) => { diff --git a/src/web/js/20-preferences-auth.js b/src/web/js/20-preferences-auth.js index bf01989..8dc4454 100644 --- a/src/web/js/20-preferences-auth.js +++ b/src/web/js/20-preferences-auth.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 20-preferences-auth.js */ + function resolveTheme(theme) { if (theme !== "system") return theme; return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; diff --git a/src/web/js/30-folders-tags.js b/src/web/js/30-folders-tags.js index 18f77dc..84b662e 100644 --- a/src/web/js/30-folders-tags.js +++ b/src/web/js/30-folders-tags.js @@ -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() { - 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 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() { + 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))}${escapeHtml(folderLeafName(folder))}${folder.unread_count || folder.total_count || ""}`; + 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"); }); - } - - 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))}${escapeHtml(folderLeafName(folder))}${folder.unread_count || folder.total_count || ""}`; - 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 = `
${escapeHtml(error.message)}
`; - } - } - - 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; + 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(); - renderMessages(); - $("#folder-title").textContent = `Label: ${tag.name}`; - $("#message-count").textContent = `${state.total} results`; - } catch (error) { toast(error.message, "error"); } + state.currentDetail = null; + renderFolders(); + closeReader(); + setFolderDrawerOpen(false); + await loadMessages(true, { restoreState: true }); + }); + row.append(expander, button); + list.append(row); } + renderMoveOptions(); +} - 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); +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 = `
${escapeHtml(error.message)}
`; } +} - 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"); } +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)); + } } +} - 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"); } +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); } - - 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; + 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); +} - 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 }]; +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(); + 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(); 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(); - await loadTags(); - toast(`Label “${tag.name}” added`); - } catch (error) { toast(error.message, "error"); } - } + toast(`Label “${tag.name}” added`); + } catch (error) { toast(error.message, "error"); } +} diff --git a/src/web/js/40-messages-reader.js b/src/web/js/40-messages-reader.js index d4a93e2..39a8be1 100644 --- a/src/web/js/40-messages-reader.js +++ b/src/web/js/40-messages-reader.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 40-messages-reader.js */ + async function loadMessages(reset = false, options = {}) { const list = $("#message-list"); const restoreState = Boolean(options.restoreState); @@ -117,7 +117,17 @@ function renderMessages() { flagButton.setAttribute("aria-label", flagButton.title); flagButton.innerHTML = svgIcon("star"); 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); if (Array.isArray(message.tags) && message.tags.length) { 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) { clearTimeout(state.markReadTimer); state.markReadTimer = null; @@ -756,6 +844,7 @@ async function importVcardAttachment(folder, uid, attachment) { function closeReader() { clearTimeout(state.markReadTimer); state.markReadTimer = null; + if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false }); $("#reader").classList.remove("mobile-open"); $("#reader-empty").classList.remove("hidden"); $("#reader-content").classList.add("hidden"); diff --git a/src/web/js/50-compose.js b/src/web/js/50-compose.js index c51de8d..6a4c3df 100644 --- a/src/web/js/50-compose.js +++ b/src/web/js/50-compose.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 50-compose.js */ + function parseRecipients(raw) { return raw.split(",").map((value) => value.trim()).filter(Boolean); } @@ -98,7 +98,7 @@ function estimatedMimeAttachmentBytes(rawBytes) { function estimateComposeMessageBytes(extraFiles = []) { 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); if (editor) { const plainBytes = utf8Bytes(editor.innerText); @@ -404,27 +404,37 @@ function setComposeUploadProgress(visible, percent = 0, label = trText("Uploadin } async function uploadComposeFiles(files, options = {}) { - const list = [...(files || [])]; - const announce = options.announce !== false; + const incoming = [...(files || [])]; + 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 (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 maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024))); const oversized = list.find((file) => Number(file.size || 0) > maxBytes); 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 []; } 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 estimatedAfterUpload = updateComposeMessageSize(list); 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; + const controller = new AbortController(); + state.composeUploadController = controller; const uploadLabel = list.length === 1 ? trText("Uploading attachment…") : trText("Uploading files…"); setComposeUploadProgress(true, 0, uploadLabel); - const progress = progressToast(uploadLabel); await saveCurrentDraft(true, true); const id = ensureDraftId(); const added = []; @@ -435,27 +445,28 @@ async function uploadComposeFiles(files, options = {}) { form.append("file", file, file.name); const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => { const overall = Math.round(((index + (percent / 100)) / list.length) * 100); - const label = `${trText("Uploading attachment…")} ${file.name}`; - setComposeUploadProgress(true, overall, `${label} · ${overall}%`); - progress.update(overall, label); - }); - added.push(...(data.attachments || [])); + setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`); + }, controller.signal); + const uploaded = data.attachments || []; + added.push(...uploaded); + 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")); - progress.complete(added.length === 1 ? trText("Attachment uploaded") : `${added.length} ${trText("attachments uploaded")}`); - if (announce) toast(`${added.length} attachment${added.length === 1 ? "" : "s"} added`, "success"); - setTimeout(() => setComposeUploadProgress(false), 450); + setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450); return added; } catch (error) { - setComposeUploadProgress(false); - progress.fail(error.message); - return []; + if (error?.name === "AbortError") setComposeUploadProgress(false); + else { + setComposeUploadProgress(true, 0, error?.message || trText("Upload failed")); + setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4800); + } + return added; } finally { + if (state.composeUploadController === controller) state.composeUploadController = null; state.composeUploading = false; } } @@ -978,8 +989,8 @@ async function recoverDraft() { async function closeComposer() { clearTimeout(state.draftSaveTimer); clearTimeout(state.inlineImageCleanupTimer); + if (state.composeUploadController) state.composeUploadController.abort(); await cleanupUnusedInlineAttachments(); - if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning"); if (state.composeNeedsRemoteSync && draftHasContent()) { const saved = await saveCurrentDraft(true, true, true); if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200); diff --git a/src/web/js/60-mail-actions-search.js b/src/web/js/60-mail-actions-search.js index 0c5946d..290d570 100644 --- a/src/web/js/60-mail-actions-search.js +++ b/src/web/js/60-mail-actions-search.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 60-mail-actions-search.js */ + async function updateFlag(flag, add) { if (!state.currentDetail) return; const detail = state.currentDetail; @@ -485,6 +485,7 @@ function openMessageContextMenu(x, y, message) { const flagged = flags.includes("\\Flagged"); showContextMenu(x, y, [ { 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 all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } }, { label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } }, diff --git a/src/web/js/70-contacts.js b/src/web/js/70-contacts.js index 73ef6ad..e553408 100644 --- a/src/web/js/70-contacts.js +++ b/src/web/js/70-contacts.js @@ -1,294 +1,294 @@ -/* RustBox UI module: 70-contacts.js */ - async function openContacts() { - $("#contacts-dialog").showModal(); - await loadContactGroups(); - await loadContacts(); - } - async function loadContactGroups() { - try { - const data = await request("/contact-groups", { method: "GET" }); - state.contactGroups = data.groups || []; - renderContactGroupFilter(); - renderContactGroupMembership($("#contact-id").value || null, []); - } catch (error) { toast(error.message, "error"); } - } +async function openContacts() { + $("#contacts-dialog").showModal(); + await loadContactGroups(); + await loadContacts(); +} - function renderContactGroupFilter() { - const select = $("#contact-group-filter"); - const current = select.value; - select.replaceChildren(new Option("All contacts", "")); - for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id)); - if ([...select.options].some((option) => option.value === current)) select.value = current; - const hasGroup = Boolean(select.value); - $("#contact-group-rename").disabled = !hasGroup; - $("#contact-group-delete").disabled = !hasGroup; - } +async function loadContactGroups() { + try { + const data = await request("/contact-groups", { method: "GET" }); + state.contactGroups = data.groups || []; + renderContactGroupFilter(); + renderContactGroupMembership($("#contact-id").value || null, []); + } catch (error) { toast(error.message, "error"); } +} - async function downloadContactExport() { - try { - const headers = new Headers(); - if (state.accountId) headers.set("X-Active-Account", state.accountId); - const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers }); - if (!response.ok) throw new Error(`Export failed (${response.status})`); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - 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"); } - } +function renderContactGroupFilter() { + const select = $("#contact-group-filter"); + const current = select.value; + select.replaceChildren(new Option("All contacts", "")); + for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id)); + if ([...select.options].some((option) => option.value === current)) select.value = current; + const hasGroup = Boolean(select.value); + $("#contact-group-rename").disabled = !hasGroup; + $("#contact-group-delete").disabled = !hasGroup; +} - async function importContactVcard(files) { - const file = files?.[0]; - if (!file) return; - if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error"); - const form = new FormData(); - form.append("file", file, file.name || "contacts.vcf"); - try { - 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`); - await loadContacts($("#contact-search").value.trim()); - } catch (error) { toast(error.message, "error"); } - finally { $("#contact-import-file").value = ""; } - } +async function downloadContactExport() { + try { + const headers = new Headers(); + if (state.accountId) headers.set("X-Active-Account", state.accountId); + const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers }); + if (!response.ok) throw new Error(`Export failed (${response.status})`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + 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 showContactDuplicates() { - try { - const data = await request("/contacts/duplicates", { method: "GET" }); - const groups = data.duplicates || []; - const list = $("#contact-duplicate-list"); - list.replaceChildren(); - if (!groups.length) { - list.append(el("div", "empty-list", "No duplicate contacts found")); - } - for (const group of groups) { - const card = el("section", "duplicate-contact-group"); - card.append(el("strong", "", group.email)); - 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 importContactVcard(files) { + const file = files?.[0]; + if (!file) return; + if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error"); + const form = new FormData(); + form.append("file", file, file.name || "contacts.vcf"); + try { + 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`); + await loadContacts($("#contact-search").value.trim()); + } catch (error) { toast(error.message, "error"); } + finally { $("#contact-import-file").value = ""; } +} - async function loadContacts(query = "") { - try { - const groupId = $("#contact-group-filter")?.value || ""; - let contacts; - if (groupId) { - 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 || []); +async function showContactDuplicates() { + try { + const data = await request("/contacts/duplicates", { method: "GET" }); + const groups = data.duplicates || []; + const list = $("#contact-duplicate-list"); list.replaceChildren(); - if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; } - for (const group of state.contactGroups) { - 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); + if (!groups.length) { + list.append(el("div", "empty-list", "No duplicate contacts found")); } - } - - function renderContacts() { - const list = $("#contact-list"); - list.replaceChildren(); - if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts")); - for (const contact of state.contacts) { - const button = el("button", "contact-item"); - button.type = "button"; - button.innerHTML = `${escapeHtml(contact.name || contact.email)}${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}`; - button.addEventListener("click", () => editContact(contact)); - list.append(button); + for (const group of groups) { + const card = el("section", "duplicate-contact-group"); + card.append(el("strong", "", group.email)); + 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"); } +} - function editContact(contact = null) { - $("#contact-id").value = contact?.id || ""; - $("#contact-name").value = contact?.name || ""; - $("#contact-email").value = contact?.email || ""; - $("#contact-emails").value = (contact?.emails || []).join("\n"); - $("#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); - } +async function loadContacts(query = "") { + try { + const groupId = $("#contact-group-filter")?.value || ""; + let contacts; + if (groupId) { + 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"); } +} - function clearContactPhotoObjectUrl() { - if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl); - state.contactPhotoUrl = null; - } +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 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 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 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 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 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 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"); } +} - 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"); } +function renderContactGroupMembership(contactId, selectedIds) { + const list = $("#contact-group-membership"); + if (!list) return; + const selected = new Set(selectedIds || []); + list.replaceChildren(); + if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; } + for (const group of state.contactGroups) { + 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); } +} - 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 renderContacts() { + const list = $("#contact-list"); + list.replaceChildren(); + if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts")); + for (const contact of state.contacts) { + const button = el("button", "contact-item"); + button.type = "button"; + button.innerHTML = `${escapeHtml(contact.name || contact.email)}${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}`; + button.addEventListener("click", () => editContact(contact)); + list.append(button); } +} - function composeToContact() { - const email = $("#contact-email").value.trim(); - if (!email) return; - $("#contacts-dialog").close(); - openCompose("new"); - $("#compose-to").value = email; - } +function editContact(contact = null) { + $("#contact-id").value = contact?.id || ""; + $("#contact-name").value = contact?.name || ""; + $("#contact-email").value = contact?.email || ""; + $("#contact-emails").value = (contact?.emails || []).join("\n"); + $("#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; +} diff --git a/src/web/js/71-calendar.js b/src/web/js/71-calendar.js index 5608e12..d5cdf78 100644 --- a/src/web/js/71-calendar.js +++ b/src/web/js/71-calendar.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 71-calendar.js */ + function startOfCalendarDay(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } diff --git a/src/web/js/72-openpgp-identities.js b/src/web/js/72-openpgp-identities.js index 83445c4..e528b29 100644 --- a/src/web/js/72-openpgp-identities.js +++ b/src/web/js/72-openpgp-identities.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 72-openpgp-identities.js */ + async function loadOpenPgp() { try { const [keys, prefs] = await Promise.all([ diff --git a/src/web/js/80-settings-realtime.js b/src/web/js/80-settings-realtime.js index fec104e..9a97e3d 100644 --- a/src/web/js/80-settings-realtime.js +++ b/src/web/js/80-settings-realtime.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 80-settings-realtime.js */ + async function loadQuota() { const summary = $("#quota-summary span"); const bar = $("#quota-bar"); @@ -158,17 +158,33 @@ function playNotificationSound() { } catch (_) { } } +async function openRealtimeNotificationTarget(accountId, data) { + window.focus(); + const folder = data?.folder || "INBOX"; + const uid = Number(data?.latest_uid); + if (accountId && accountId !== state.accountId) await switchAccount(accountId); + if (folder !== state.folder) { + state.folder = folder; + renderFolders(); + await loadMessages(true); + } + if (Number.isFinite(uid) && uid > 0) await openMessage(folder, uid); +} + function notifyNewMail(data, accountId) { const prefs = state.notificationPrefs || {}; if (!prefs.enabled || dndActive(prefs)) return; const folders = prefs.folders || []; if (folders.length && !folders.includes(data.folder)) return; + const uid = Number(data.latest_uid); + const alreadyOpen = Number(data.count || 1) === 1 && accountId === state.accountId && data.folder === state.currentDetail?.folder && Number.isFinite(uid) && uid === Number(state.currentDetail?.uid) && document.visibilityState === "visible" && document.hasFocus(); + if (alreadyOpen) return; if ("Notification" in window && Notification.permission === "granted") { const account = state.accounts.find((item) => item.id === accountId); const title = data.latest_sender || account?.email || "New mail"; const body = data.latest_subject || `${data.count || 1} new message(s) in ${data.folder}`; const notification = new Notification(title, { body, tag: `rustbox-${accountId}-${data.folder}`, renotify: true }); - notification.onclick = () => { window.focus(); notification.close(); }; + notification.onclick = () => { notification.close(); void openRealtimeNotificationTarget(accountId, data); }; } if (prefs.sound) playNotificationSound(); } @@ -216,48 +232,141 @@ function startCalendarReminderWatcher() { state.calendarReminderTimer = setInterval(() => { void checkCalendarReminders(); }, 30000); } +function realtimeEventKey(payload) { + if (payload.eventId) return String(payload.eventId); + const event = payload.event || {}; + return `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`; +} + +function isDuplicateRealtimeEvent(payload) { + const now = Date.now(); + for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key); + const event = payload.event || {}; + const fingerprint = `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`; + const idKey = payload.eventId ? `id:${payload.eventId}` : null; + const fingerprintKey = `event:${fingerprint}`; + const duplicate = Boolean((idKey && state.realtimeSeenEvents.has(idKey)) || (state.realtimeSeenEvents.get(fingerprintKey) && now - state.realtimeSeenEvents.get(fingerprintKey) < 1500)); + if (idKey) state.realtimeSeenEvents.set(idKey, now); + state.realtimeSeenEvents.set(fingerprintKey, now); + return duplicate; +} + +async function resyncRealtimeState() { + if (!state.accountId) return; + if (state.realtimeSyncPromise) return state.realtimeSyncPromise; + state.realtimeSyncPromise = (async () => { + await refreshFolderListOnly(); + if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }); + else await loadMessages(true); + })().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; }); + return state.realtimeSyncPromise; +} + function scheduleRealtimeRefresh(event, accountId) { if (accountId !== state.accountId) return; + state.realtimeRefreshEvents.push(event); clearTimeout(state.realtimeRefreshTimer); state.realtimeRefreshTimer = setTimeout(async () => { + const events = state.realtimeRefreshEvents.splice(0); await refreshFolderListOnly(); - if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) { + const refreshCurrent = events.some((item) => ["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder); + if (refreshCurrent) { if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }); else await loadMessages(true); } - }, 250); + }, 180); +} + +function startRealtimeFallbackPolling() { + if (state.realtimePollTimer) return; + state.realtimePollTimer = setInterval(() => { + if (!state.accounts.length || document.visibilityState === "hidden") return; + const open = state.ws?.readyState === WebSocket.OPEN; + const stale = !state.wsLastHeartbeatAt || Date.now() - state.wsLastHeartbeatAt > 55000; + if (!open || stale) void resyncRealtimeState(); + }, 30000); +} + +function startRealtimeWatchdog(socket) { + clearInterval(state.wsWatchdogTimer); + state.wsWatchdogTimer = setInterval(() => { + if (state.ws !== socket || socket.readyState !== WebSocket.OPEN) return; + if (Date.now() - state.wsLastHeartbeatAt > 60000) socket.close(4000, "Heartbeat timeout"); + }, 10000); +} + +function scheduleRealtimeReconnect() { + if (!state.wsShouldConnect || !state.accounts.length || state.wsRetryTimer) return; + const attempt = Math.min(state.wsRetryAttempt, 6); + const base = Math.min(30000, 1000 * (2 ** attempt)); + const delay = Math.round(base * (0.8 + Math.random() * 0.4)); + state.wsRetryAttempt += 1; + state.wsRetryTimer = setTimeout(() => { + state.wsRetryTimer = null; + connectRealtime(); + }, delay); } function connectRealtime() { + state.wsShouldConnect = true; + startRealtimeFallbackPolling(); if (!state.accounts.length) return; if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return; clearTimeout(state.wsRetryTimer); + state.wsRetryTimer = null; const protocol = location.protocol === "https:" ? "wss:" : "ws:"; const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`); state.ws = socket; + socket.onopen = () => { + if (state.ws !== socket) return; + const reconnect = state.wsConnectedOnce; + state.wsConnectedOnce = true; + state.wsRetryAttempt = 0; + state.wsLastHeartbeatAt = Date.now(); + startRealtimeWatchdog(socket); + if (reconnect) void resyncRealtimeState(); + }; socket.onmessage = (message) => { + if (state.ws !== socket) return; + state.wsLastHeartbeatAt = Date.now(); try { const payload = JSON.parse(message.data); + if (payload.type === "heartbeat") { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "heartbeat_ack", timestamp: payload.timestamp || Date.now() })); + return; + } + if (isDuplicateRealtimeEvent(payload)) return; const event = payload.event || {}; if (event.type === "NewMessages") notifyNewMail(event.data || {}, payload.accountId); if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId); } catch (error) { console.warn("Invalid realtime event", error); } }; socket.onclose = () => { - if (state.ws === socket) state.ws = null; - if (state.accounts.length) state.wsRetryTimer = setTimeout(connectRealtime, 2500); + if (state.ws !== socket) return; + state.ws = null; + clearInterval(state.wsWatchdogTimer); + state.wsWatchdogTimer = null; + scheduleRealtimeReconnect(); }; - socket.onerror = () => socket.close(); + socket.onerror = () => { if (socket.readyState < WebSocket.CLOSING) socket.close(); }; } function disconnectRealtime() { + state.wsShouldConnect = false; clearTimeout(state.wsRetryTimer); clearTimeout(state.realtimeRefreshTimer); + clearInterval(state.wsWatchdogTimer); + clearInterval(state.realtimePollTimer); state.wsRetryTimer = null; state.realtimeRefreshTimer = null; + state.wsWatchdogTimer = null; + state.realtimePollTimer = null; + state.realtimeRefreshEvents = []; + state.wsRetryAttempt = 0; + state.wsLastHeartbeatAt = 0; const socket = state.ws; state.ws = null; - if (socket && socket.readyState < WebSocket.CLOSING) socket.close(); + if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect"); } function capabilityBadge(label, supported) { diff --git a/src/web/js/90-sieve.js b/src/web/js/90-sieve.js index 6631249..5e406c5 100644 --- a/src/web/js/90-sieve.js +++ b/src/web/js/90-sieve.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 90-sieve.js */ + async function loadSieve() { const status = $("#sieve-status"); status.textContent = "Connecting…"; @@ -960,7 +960,7 @@ async function restoreSieveBackup(file) { ); if (!confirmed) return; - // Validate the complete backup against the current server before writing any script. + for (const script of scripts) { const result = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: script.content }) }); if (!result.valid) throw new Error(`Script “${script.name}” is not valid on this server: ${result.error || "validation failed"}`); diff --git a/src/web/js/95-bindings-boot.js b/src/web/js/95-bindings-boot.js index 2faf512..fb686fe 100644 --- a/src/web/js/95-bindings-boot.js +++ b/src/web/js/95-bindings-boot.js @@ -1,4 +1,4 @@ -/* RustBox UI module: 95-bindings-boot.js */ + function bindEditor() { $$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => { $("#compose-editor").focus(); @@ -66,15 +66,12 @@ function positionToolbarMenu(menu) { const popover = menu.querySelector(".toolbar-menu-popover"); const summary = menu.querySelector("summary"); if (!popover || !summary) return; - if (matchMedia("(max-width: 560px)").matches) { - Object.assign(popover.style, { left: "8px", right: "8px", top: "auto", bottom: "8px" }); - return; - } Object.assign(popover.style, { right: "auto", bottom: "auto" }); const rect = summary.getBoundingClientRect(); - const width = Math.max(164, popover.offsetWidth || 164); - const height = popover.offsetHeight || 80; const margin = 8; + const mobile = matchMedia("(max-width: 560px)").matches; + const width = Math.min(Math.max(mobile ? 180 : 164, popover.offsetWidth || 164), window.innerWidth - (margin * 2)); + const height = popover.offsetHeight || 80; let left = Math.min(rect.left, window.innerWidth - width - margin); left = Math.max(margin, left); let top = rect.bottom + 5; @@ -256,6 +253,7 @@ function bindEvents() { $("#reader-back").addEventListener("click", closeReader); $("#reader-prev").addEventListener("click", () => navigateMessage(-1)); $("#reader-next").addEventListener("click", () => navigateMessage(1)); + $("#reader-fullscreen").addEventListener("click", toggleReaderFullscreen); $("#thread-toggle").addEventListener("click", () => { state.threadExpanded = !state.threadExpanded; localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded)); @@ -484,6 +482,11 @@ function bindEvents() { else if (event.key === "!") { event.preventDefault(); junkCurrent(); } else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); } } + if (event.key === "Escape" && $("#reader")?.classList.contains("reader-fullscreen")) { + event.preventDefault(); + exitReaderFullscreen(); + return; + } if (event.key === "Escape" && innerWidth <= 820) { if ($("#folder-pane")?.classList.contains("open")) setFolderDrawerOpen(false); else closeReader(); @@ -494,6 +497,16 @@ function bindEvents() { else if (state.prefs.theme === "system") applyPreferences(); }); window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); }); + window.addEventListener("popstate", async (event) => { + const full = event.state?.rustboxMailFullscreen === true; + if (!full) { + if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false }); + return; + } + const folder = event.state.folder; + const uid = Number(event.state.uid); + if (folder && Number.isFinite(uid)) await openMessageTarget(state.accountId, folder, uid, true); + }); } async function boot() { @@ -501,7 +514,13 @@ async function boot() { bindEvents(); await loadAuthPolicy(); const authenticated = await loadAccounts(); - if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); } + if (authenticated) { + await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); + const params = new URLSearchParams(location.search); + const folder = params.get("mail_folder"); + const uid = Number(params.get("mail_uid")); + if (folder && Number.isFinite(uid) && uid > 0) await openMessageTarget(state.accountId, folder, uid, params.get("mail_fullscreen") === "1"); + } else { await loadTranslations(state.prefs.language); } } boot(); diff --git a/tests/roundcube_skin_contract.py b/tests/roundcube_skin_contract.py index 53e9a03..41589e6 100644 --- a/tests/roundcube_skin_contract.py +++ b/tests/roundcube_skin_contract.py @@ -101,7 +101,7 @@ def test_modular_js_contract(): assert "translateScopedUiText" in app assert "presetScaleUnlocked" in app assert "positionToolbarMenu" in app - assert "progressToast" in app + assert "progressToast" not in app assert "attachmentPreviewLimitBytes" in app assert 'label: "Preview"' in app assert "This attachment is too large for preview. Download it instead?" in app @@ -119,7 +119,7 @@ def test_backend_skin_contract_and_version(): for value in ["rustbox", "roundcube-classic", "roundcube-larry", "roundcube-elastic", "clean", "gmail", "proton", "zoho", "outlook", "minimal", "graphite", "high-contrast", "fastmail", "apple-mail", "yahoo", "hey", "nord"]: assert f'"{value}"' in db_source cargo = (ROOT / "Cargo.toml").read_text() - assert 'version = "0.0.4"' in cargo + assert 'version = "0.0.5"' in cargo config = (ROOT / "src/config.rs").read_text() assert "pub default_theme: String" in config assert '"system" | "dark" | "light"' in config @@ -135,7 +135,7 @@ def test_backend_skin_contract_and_version(): def test_mobile_003_contract(): responsive = (ROOT / "src/web/css/60-responsive.css").read_text() assert '.sieve-builder .checkbox-row input[type="checkbox"]' in responsive - assert ".dialog > .dialog-card" in responsive + assert ".dialog>.dialog-card" in responsive assert "grid-template-rows: auto minmax(0, 1fr)" in responsive assert ".calendar-toolbar" in responsive assert ".message-meta.addresses-expanded" in responsive diff --git a/tests/test_release_005_contract.py b/tests/test_release_005_contract.py new file mode 100644 index 0000000..8df72a1 --- /dev/null +++ b/tests/test_release_005_contract.py @@ -0,0 +1,52 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +def read(path): + return (ROOT / path).read_text() + +def test_release_version(): + assert 'version = "0.0.5"' in read("Cargo.toml") + assert "# RustBox 0.0.5" in read("README.md") + +def test_realtime_contract(): + ws = read("src/realtime/ws.rs") + js = read("src/web/js/80-settings-realtime.js") + assert '"type": "heartbeat"' in ws + assert "Heartbeat timeout" in js + assert "2 ** attempt" in js + assert "resyncRealtimeState" in js + assert "realtimeSeenEvents" in js + assert "realtimePollTimer" in js + assert "realtimeRefreshEvents" in js + +def test_upload_progress_is_not_toast_driven(): + compose = read("src/web/js/50-compose.js") + block = compose[compose.index("async function uploadComposeFiles"):compose.index("function previewMarkdown")] + assert "progressToast(" not in block + assert "setComposeUploadProgress" in block + assert "AbortController" in block + +def test_fullscreen_reader_contract(): + html = read("src/web/index.html") + reader = read("src/web/js/40-messages-reader.js") + menu = read("src/web/js/60-mail-actions-search.js") + css = read("src/web/css/30-mail.css") + assert 'id="reader-fullscreen"' in html + assert "Open full screen" in menu + assert "message-fullscreen-action" in reader + assert "reader-fullscreen" in reader + assert "history.pushState" in reader + assert "reader-pane.reader-fullscreen" in css + assert ".reader-pane.reader-fullscreen .message-body" in css + assert "max-width: none" in css + +def test_mobile_reader_contract(): + css = read("src/web/css/30-mail.css") + responsive = read("src/web/css/60-responsive.css") + bindings = read("src/web/js/95-bindings-boot.js") + assert ".toolbar-menu-popover button" in css + assert ".attachment-item .attachment-button" in css + assert "min-height: 34px" in responsive + assert 'bottom: "8px"' not in bindings + assert 'rect.bottom + 5' in bindings