1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
@@ -1,5 +1,17 @@
|
|||||||
# RustBox changelog
|
# RustBox changelog
|
||||||
|
|
||||||
|
### 0.0.5
|
||||||
|
- Fixed the mobile reader toolbar regression so compact controls keep their original height and Message details / Move-copy menus open next to the triggering control instead of at the bottom of the viewport.
|
||||||
|
- Full-screen message reading now overrides the normal reader width limit so the message workspace uses the available screen width.
|
||||||
|
- Fixed mobile reader actions, attachment layout, long subjects, and touch targets.
|
||||||
|
- Added WebSocket application heartbeat, dead-connection detection, exponential reconnect, reconnect resync, event deduplication, and fallback polling.
|
||||||
|
- Fixed live message refresh coalescing so folder updates cannot suppress a pending new-message refresh.
|
||||||
|
- Improved new-mail notifications and made notification clicks open the target message when its UID is available.
|
||||||
|
- Added message full-screen mode from the reader button and context menu with Escape, browser history, deep links, and scroll restoration.
|
||||||
|
- Kept attachment upload progress only in the composer progress bar and added duplicate/cancellation safeguards.
|
||||||
|
- Removed source-code comments and added 0.0.5 regression contracts.
|
||||||
|
- Bumped the application/source release to `0.0.5`.
|
||||||
|
|
||||||
### 0.0.4
|
### 0.0.4
|
||||||
- Changed attachment left-click behavior to prefer in-app preview for supported files while keeping direct download for non-previewable formats.
|
- Changed attachment left-click behavior to prefer in-app preview for supported files while keeping direct download for non-previewable formats.
|
||||||
- Added attachment right-click actions for explicit Preview and Download.
|
- Added attachment right-click actions for explicit Preview and Download.
|
||||||
|
|||||||
Generated
+1
-1
@@ -2957,7 +2957,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.0.4"
|
version = "0.0.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-imap",
|
"async-imap",
|
||||||
"async-native-tls",
|
"async-native-tls",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustbox"
|
name = "rustbox"
|
||||||
version = "0.0.4"
|
version = "0.0.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
default-run = "rustbox"
|
default-run = "rustbox"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RustBox attribution / notice
|
# RustBox attribution / notice
|
||||||
|
|
||||||
RustBox 0.0.4 is derived from the Oxi webmail project:
|
RustBox 0.0.5 is derived from the Oxi webmail project:
|
||||||
|
|
||||||
- Upstream: `c0h1b4/oxi`
|
- Upstream: `c0h1b4/oxi`
|
||||||
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.0.4
|
# RustBox 0.0.5
|
||||||
|
|
||||||
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
|
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
|
||||||
|
|
||||||
@@ -71,9 +71,6 @@ If the mail host settings are omitted, RustBox uses `localhost` with IMAP `993`
|
|||||||
|
|
||||||
Mail-size limits are intentionally separate. `max_attachment_size_mb` applies to each raw uploaded file, while `max_message_size_mb` is checked against the final RFC822/MIME byte stream immediately before SMTP submission. The latter therefore includes message bodies, headers, MIME boundaries, base64 expansion, inline CID parts and OpenPGP transformations. The composer displays only an estimate; the server-side final-MIME check is authoritative.
|
Mail-size limits are intentionally separate. `max_attachment_size_mb` applies to each raw uploaded file, while `max_message_size_mb` is checked against the final RFC822/MIME byte stream immediately before SMTP submission. The latter therefore includes message bodies, headers, MIME boundaries, base64 expansion, inline CID parts and OpenPGP transformations. The composer displays only an estimate; the server-side final-MIME check is authoritative.
|
||||||
|
|
||||||
## Current 0.0.4 UI snapshot
|
|
||||||
|
|
||||||
The internal baseline includes persistent appearance/layout preferences, draggable mail-pane splitters, action toasts, recipient autocomplete, attachment preview/context actions and client-side ZIP download in addition to the existing IMAP/Sieve/mail workflow. The authoritative completion scope remains `docs/RUSTBOX_PLAN.md`; incomplete items are tracked in `docs/IMPLEMENTATION_STATUS.md`.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -141,7 +138,7 @@ Initialize Git after extracting the release archive:
|
|||||||
```bash
|
```bash
|
||||||
git init
|
git init
|
||||||
git add .
|
git add .
|
||||||
git commit -m "RustBox 0.0.4 release"
|
git commit -m "RustBox 0.0.5 release"
|
||||||
./scripts/setup-upstream.sh
|
./scripts/setup-upstream.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -1,4 +1,4 @@
|
|||||||
RustBox 0.0.4 source release
|
RustBox 0.0.5 source release
|
||||||
|
|
||||||
Runtime/source model:
|
Runtime/source model:
|
||||||
- one root Cargo package
|
- one root Cargo package
|
||||||
@@ -19,7 +19,7 @@ Oxi tracking:
|
|||||||
- Oxi frontend changes are reviewed and manually ported to src/web/
|
- Oxi frontend changes are reviewed and manually ported to src/web/
|
||||||
- docs/UI.md documents the browser UI/build conventions
|
- docs/UI.md documents the browser UI/build conventions
|
||||||
|
|
||||||
Current 0.0.4 release additions:
|
Current 0.0.5 release additions:
|
||||||
- attachment left-click defaults to safe in-app preview for supported types
|
- attachment left-click defaults to safe in-app preview for supported types
|
||||||
- attachment right-click exposes explicit Preview and Download actions
|
- attachment right-click exposes explicit Preview and Download actions
|
||||||
- oversized preview attempts offer direct download instead of ending with a warning
|
- oversized preview attempts offer direct download instead of ending with a warning
|
||||||
@@ -64,3 +64,12 @@ Current 0.0.4 release additions:
|
|||||||
Validation note:
|
Validation note:
|
||||||
- JavaScript, JSON, TOML, SVG, shell syntax, UI selector/icon integrity and SQLite migrations were validated in the release environment.
|
- JavaScript, JSON, TOML, SVG, shell syntax, UI selector/icon integrity and SQLite migrations were validated in the release environment.
|
||||||
- A full cargo build/test was not executed in this environment because cargo/rustc are not installed; run ./dev.sh or cargo test on a Rust toolchain host before deployment.
|
- A full cargo build/test was not executed in this environment because cargo/rustc are not installed; run ./dev.sh or cargo test on a Rust toolchain host before deployment.
|
||||||
|
|
||||||
|
0.0.5 additions:
|
||||||
|
- mobile reader action and attachment fixes
|
||||||
|
- WebSocket heartbeat, reconnect backoff, watchdog, resync, dedupe and fallback polling
|
||||||
|
- reliable live new-message refresh and unread reconciliation
|
||||||
|
- notification click-through to the newest message when UID is available
|
||||||
|
- full-screen message reader with context-menu entry, Escape, history and deep links
|
||||||
|
- composer-only upload progress with duplicate and cancellation safeguards
|
||||||
|
- source-code comment removal and regression contracts
|
||||||
@@ -14,18 +14,11 @@ fn fnv1a64(parts: &[&[u8]]) -> u64 {
|
|||||||
hash
|
hash
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bundle_manifest(
|
fn bundle_manifest(root: &Path, directory: &Path, manifest: &Path, extension: &str) -> String {
|
||||||
root: &Path,
|
|
||||||
directory: &Path,
|
|
||||||
manifest: &Path,
|
|
||||||
extension: &str,
|
|
||||||
banner: &str,
|
|
||||||
) -> String {
|
|
||||||
println!("cargo:rerun-if-changed={}", manifest.display());
|
println!("cargo:rerun-if-changed={}", manifest.display());
|
||||||
let manifest_text = fs::read_to_string(manifest)
|
let manifest_text = fs::read_to_string(manifest)
|
||||||
.unwrap_or_else(|error| panic!("{}: {error}", manifest.display()));
|
.unwrap_or_else(|error| panic!("{}: {error}", manifest.display()));
|
||||||
let mut bundle = String::from(banner);
|
let mut bundle = String::new();
|
||||||
bundle.push('\n');
|
|
||||||
|
|
||||||
for raw in manifest_text.lines() {
|
for raw in manifest_text.lines() {
|
||||||
let name = raw.trim();
|
let name = raw.trim();
|
||||||
@@ -48,9 +41,9 @@ fn bundle_manifest(
|
|||||||
println!("cargo:rerun-if-changed={}", path.display());
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
let source =
|
let source =
|
||||||
fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
||||||
bundle.push_str("\n/* ---- ");
|
if !bundle.is_empty() {
|
||||||
bundle.push_str(name);
|
bundle.push('\n');
|
||||||
bundle.push_str(" ---- */\n");
|
}
|
||||||
bundle.push_str(&source);
|
bundle.push_str(&source);
|
||||||
if !source.ends_with('\n') {
|
if !source.ends_with('\n') {
|
||||||
bundle.push('\n');
|
bundle.push('\n');
|
||||||
@@ -75,20 +68,8 @@ fn main() {
|
|||||||
println!("cargo:rerun-if-changed={}", path.display());
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
let css_bundle = bundle_manifest(
|
let css_bundle = bundle_manifest(&root, &css_dir, &css_dir.join("manifest.txt"), ".css");
|
||||||
&root,
|
let js_bundle = bundle_manifest(&root, &js_dir, &js_dir.join("manifest.txt"), ".js");
|
||||||
&css_dir,
|
|
||||||
&css_dir.join("manifest.txt"),
|
|
||||||
".css",
|
|
||||||
"/* RustBox UI CSS bundle - generated by build.rs */",
|
|
||||||
);
|
|
||||||
let js_bundle = bundle_manifest(
|
|
||||||
&root,
|
|
||||||
&js_dir,
|
|
||||||
&js_dir.join("manifest.txt"),
|
|
||||||
".js",
|
|
||||||
"/* RustBox UI JavaScript bundle - generated by build.rs; edit src/web/js/*.js */",
|
|
||||||
);
|
|
||||||
|
|
||||||
let asset_id = fnv1a64(&[css_bundle.as_bytes(), js_bundle.as_bytes()]);
|
let asset_id = fnv1a64(&[css_bundle.as_bytes(), js_bundle.as_bytes()]);
|
||||||
println!("cargo:rustc-env=RUSTBOX_ASSET_ID={asset_id:016x}");
|
println!("cargo:rustc-env=RUSTBOX_ASSET_ID={asset_id:016x}");
|
||||||
|
|||||||
@@ -27,4 +27,3 @@ sieve_allow_insecure_auth = false
|
|||||||
sieve_timeout_secs = 10
|
sieve_timeout_secs = 10
|
||||||
|
|
||||||
trusted_proxies = "127.0.0.1,::1"
|
trusted_proxies = "127.0.0.1,::1"
|
||||||
|
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# RustBox 0.0.4
|
# RustBox 0.0.5
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port = 3001
|
port = 3001
|
||||||
environment = "production"
|
environment = "production"
|
||||||
@@ -7,7 +7,7 @@ default_theme = "system"
|
|||||||
# Global interface style used by the login screen and by accounts set to
|
# Global interface style used by the login screen and by accounts set to
|
||||||
# "Global default". Presets include gmail, proton, zoho, outlook, fastmail,
|
# "Global default". Presets include gmail, proton, zoho, outlook, fastmail,
|
||||||
# apple-mail, yahoo, hey, nord, minimal and Roundcube variants.
|
# apple-mail, yahoo, hey, nord, minimal and Roundcube variants.
|
||||||
default_interface_skin = "rustbox"
|
default_interface_skin = "gmail"
|
||||||
data_dir = "/var/lib/rustbox"
|
data_dir = "/var/lib/rustbox"
|
||||||
session_timeout_hours = 24
|
session_timeout_hours = 24
|
||||||
remember_me_enabled = true
|
remember_me_enabled = true
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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/`.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -45,9 +45,6 @@ tmp_mapped="$(mktemp)"
|
|||||||
cleanup() { rm -f "$tmp_raw" "$tmp_mapped"; }
|
cleanup() { rm -f "$tmp_raw" "$tmp_mapped"; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
# RustBox flattened Oxi's backend/ crate into the repository root. Generate
|
|
||||||
# only the upstream backend delta, then rewrite patch metadata from
|
|
||||||
# backend/<path> to <path>. File contents are not rewritten.
|
|
||||||
git diff --binary --find-renames "$BASE..$TARGET_SHA" -- backend/ > "$tmp_raw"
|
git diff --binary --find-renames "$BASE..$TARGET_SHA" -- backend/ > "$tmp_raw"
|
||||||
sed \
|
sed \
|
||||||
-e 's#^diff --git a/backend/#diff --git a/#' \
|
-e 's#^diff --git a/backend/#diff --git a/#' \
|
||||||
|
|||||||
+5
-26
@@ -3,24 +3,9 @@ use axum::http::{Method, StatusCode};
|
|||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
||||||
/// Methods that mutate state and therefore require CSRF protection.
|
const STATE_CHANGING_METHODS: [Method; 4] =
|
||||||
const STATE_CHANGING_METHODS: [Method; 4] = [
|
[Method::POST, Method::PUT, Method::DELETE, Method::PATCH];
|
||||||
Method::POST,
|
|
||||||
Method::PUT,
|
|
||||||
Method::DELETE,
|
|
||||||
Method::PATCH,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Axum middleware that enforces CSRF protection on state-changing requests.
|
|
||||||
///
|
|
||||||
/// For POST, PUT, DELETE, and PATCH requests the `X-Requested-With` header
|
|
||||||
/// must be present (any non-empty value is accepted). GET, HEAD, OPTIONS, and
|
|
||||||
/// all other safe methods pass through without the check.
|
|
||||||
///
|
|
||||||
/// This works in concert with `SameSite=Strict` session cookies: the cookie
|
|
||||||
/// attribute prevents the browser from sending credentials on cross-origin
|
|
||||||
/// requests, while this header check blocks cross-origin form submissions
|
|
||||||
/// (browsers do not allow custom headers in simple/form requests).
|
|
||||||
pub async fn csrf_protection(request: Request, next: Next) -> Response {
|
pub async fn csrf_protection(request: Request, next: Next) -> Response {
|
||||||
if STATE_CHANGING_METHODS.contains(request.method())
|
if STATE_CHANGING_METHODS.contains(request.method())
|
||||||
&& !request.headers().contains_key("x-requested-with")
|
&& !request.headers().contains_key("x-requested-with")
|
||||||
@@ -46,24 +31,19 @@ pub async fn csrf_protection(request: Request, next: Next) -> Response {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use axum::Router;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
/// Build a minimal router with CSRF middleware applied.
|
|
||||||
fn app() -> Router {
|
fn app() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/test", get(|| async { "ok" }).post(|| async { "ok" }))
|
.route("/test", get(|| async { "ok" }).post(|| async { "ok" }))
|
||||||
.layer(axum::middleware::from_fn(csrf_protection))
|
.layer(axum::middleware::from_fn(csrf_protection))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: send a request and return the status code and parsed JSON body.
|
async fn send(method: Method, with_header: bool) -> (StatusCode, serde_json::Value) {
|
||||||
async fn send(
|
|
||||||
method: Method,
|
|
||||||
with_header: bool,
|
|
||||||
) -> (StatusCode, serde_json::Value) {
|
|
||||||
let mut builder = Request::builder().method(method).uri("/test");
|
let mut builder = Request::builder().method(method).uri("/test");
|
||||||
|
|
||||||
if with_header {
|
if with_header {
|
||||||
@@ -182,8 +162,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let response = app.oneshot(request).await.unwrap();
|
let response = app.oneshot(request).await.unwrap();
|
||||||
// OPTIONS may return 200 or other codes depending on router config,
|
|
||||||
// but it must NOT return 403 from CSRF middleware.
|
|
||||||
assert_ne!(response.status(), StatusCode::FORBIDDEN);
|
assert_ne!(response.status(), StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+13
-35
@@ -1,23 +1,14 @@
|
|||||||
use async_imap::error::Error as ImapError;
|
use async_imap::error::Error as ImapError;
|
||||||
|
|
||||||
/// Result of validating credentials against an IMAP server.
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub enum AuthResult {
|
pub enum AuthResult {
|
||||||
/// The credentials are valid; login succeeded (and we immediately logged out).
|
|
||||||
Success,
|
Success,
|
||||||
/// The server rejected the credentials (wrong email or password).
|
|
||||||
InvalidCredentials,
|
InvalidCredentials,
|
||||||
/// Could not reach or communicate with the IMAP server.
|
|
||||||
ServerUnreachable(String),
|
ServerUnreachable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate an email/password pair against a real IMAP server.
|
|
||||||
///
|
|
||||||
/// Connects to the given `host:port`, optionally using TLS, attempts a LOGIN
|
|
||||||
/// command with the provided credentials, and immediately logs out on success.
|
|
||||||
///
|
|
||||||
/// This function does **not** maintain a persistent IMAP connection; it is
|
|
||||||
/// intended solely for credential validation at login time.
|
|
||||||
pub async fn validate_imap_credentials(
|
pub async fn validate_imap_credentials(
|
||||||
host: &str,
|
host: &str,
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -33,9 +24,13 @@ pub async fn validate_imap_credentials(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TLS path: connect via `tokio::net::TcpStream`, upgrade with
|
async fn validate_tls(
|
||||||
/// `async_native_tls::TlsConnector`, then wrap in `async_imap::Client`.
|
host: &str,
|
||||||
async fn validate_tls(host: &str, port: u16, tls_verify: bool, email: &str, password: &str) -> AuthResult {
|
port: u16,
|
||||||
|
tls_verify: bool,
|
||||||
|
email: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> AuthResult {
|
||||||
let addr = format!("{host}:{port}");
|
let addr = format!("{host}:{port}");
|
||||||
|
|
||||||
let tcp_stream = match tokio::net::TcpStream::connect(&addr).await {
|
let tcp_stream = match tokio::net::TcpStream::connect(&addr).await {
|
||||||
@@ -53,7 +48,6 @@ async fn validate_tls(host: &str, port: u16, tls_verify: bool, email: &str, pass
|
|||||||
|
|
||||||
let mut client = async_imap::Client::new(tls_stream);
|
let mut client = async_imap::Client::new(tls_stream);
|
||||||
|
|
||||||
// Read the server greeting before attempting login.
|
|
||||||
match client.read_response().await {
|
match client.read_response().await {
|
||||||
Some(Ok(_)) => {}
|
Some(Ok(_)) => {}
|
||||||
Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()),
|
Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()),
|
||||||
@@ -63,8 +57,6 @@ async fn validate_tls(host: &str, port: u16, tls_verify: bool, email: &str, pass
|
|||||||
attempt_login(client, email, password).await
|
attempt_login(client, email, password).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-TLS path: connect via `tokio::net::TcpStream`, then wrap directly in
|
|
||||||
/// `async_imap::Client`.
|
|
||||||
async fn validate_plain(host: &str, port: u16, email: &str, password: &str) -> AuthResult {
|
async fn validate_plain(host: &str, port: u16, email: &str, password: &str) -> AuthResult {
|
||||||
let addr = format!("{host}:{port}");
|
let addr = format!("{host}:{port}");
|
||||||
|
|
||||||
@@ -75,7 +67,6 @@ async fn validate_plain(host: &str, port: u16, email: &str, password: &str) -> A
|
|||||||
|
|
||||||
let mut client = async_imap::Client::new(tcp_stream);
|
let mut client = async_imap::Client::new(tcp_stream);
|
||||||
|
|
||||||
// Read the server greeting before attempting login.
|
|
||||||
match client.read_response().await {
|
match client.read_response().await {
|
||||||
Some(Ok(_)) => {}
|
Some(Ok(_)) => {}
|
||||||
Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()),
|
Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()),
|
||||||
@@ -85,14 +76,12 @@ async fn validate_plain(host: &str, port: u16, email: &str, password: &str) -> A
|
|||||||
attempt_login(client, email, password).await
|
attempt_login(client, email, password).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared login logic: attempt LOGIN, logout on success, classify errors.
|
|
||||||
async fn attempt_login<T>(client: async_imap::Client<T>, email: &str, password: &str) -> AuthResult
|
async fn attempt_login<T>(client: async_imap::Client<T>, email: &str, password: &str) -> AuthResult
|
||||||
where
|
where
|
||||||
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + std::fmt::Debug + Send,
|
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + std::fmt::Debug + Send,
|
||||||
{
|
{
|
||||||
match client.login(email, password).await {
|
match client.login(email, password).await {
|
||||||
Ok(mut session) => {
|
Ok(mut session) => {
|
||||||
// Successfully authenticated; clean up.
|
|
||||||
let _ = session.logout().await;
|
let _ = session.logout().await;
|
||||||
AuthResult::Success
|
AuthResult::Success
|
||||||
}
|
}
|
||||||
@@ -100,11 +89,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify an IMAP error into `InvalidCredentials` or `ServerUnreachable`.
|
|
||||||
///
|
|
||||||
/// A `No` response from the server means the credentials were rejected.
|
|
||||||
/// Everything else (I/O errors, BAD responses, parse errors, etc.) is
|
|
||||||
/// treated as a server/network problem.
|
|
||||||
fn classify_login_error(err: ImapError) -> AuthResult {
|
fn classify_login_error(err: ImapError) -> AuthResult {
|
||||||
match err {
|
match err {
|
||||||
ImapError::No(_) => AuthResult::InvalidCredentials,
|
ImapError::No(_) => AuthResult::InvalidCredentials,
|
||||||
@@ -116,14 +100,11 @@ fn classify_login_error(err: ImapError) -> AuthResult {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Connecting to an unreachable server should return `ServerUnreachable`.
|
|
||||||
///
|
|
||||||
/// We use 127.0.0.1:19999, a port that (with very high probability) has
|
|
||||||
/// nothing listening on localhost.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unreachable_server_returns_server_unreachable() {
|
async fn unreachable_server_returns_server_unreachable() {
|
||||||
let result =
|
let result =
|
||||||
validate_imap_credentials("127.0.0.1", 19999, false, false, "user@test.com", "pass").await;
|
validate_imap_credentials("127.0.0.1", 19999, false, false, "user@test.com", "pass")
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result, AuthResult::ServerUnreachable(_)),
|
matches!(result, AuthResult::ServerUnreachable(_)),
|
||||||
@@ -131,11 +112,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same test but with TLS enabled: should also return `ServerUnreachable`.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unreachable_server_tls_returns_server_unreachable() {
|
async fn unreachable_server_tls_returns_server_unreachable() {
|
||||||
let result =
|
let result =
|
||||||
validate_imap_credentials("127.0.0.1", 19999, true, false, "user@test.com", "pass").await;
|
validate_imap_credentials("127.0.0.1", 19999, true, false, "user@test.com", "pass")
|
||||||
|
.await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result, AuthResult::ServerUnreachable(_)),
|
matches!(result, AuthResult::ServerUnreachable(_)),
|
||||||
@@ -143,14 +124,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify the error classifier maps `No` to `InvalidCredentials`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_no_as_invalid_credentials() {
|
fn classify_no_as_invalid_credentials() {
|
||||||
let err = ImapError::No("Authentication failed".into());
|
let err = ImapError::No("Authentication failed".into());
|
||||||
assert_eq!(classify_login_error(err), AuthResult::InvalidCredentials);
|
assert_eq!(classify_login_error(err), AuthResult::InvalidCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify the error classifier maps I/O errors to `ServerUnreachable`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_io_as_server_unreachable() {
|
fn classify_io_as_server_unreachable() {
|
||||||
let err = ImapError::Io(std::io::Error::new(
|
let err = ImapError::Io(std::io::Error::new(
|
||||||
@@ -164,7 +143,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify the error classifier maps `Bad` responses to `ServerUnreachable`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_bad_as_server_unreachable() {
|
fn classify_bad_as_server_unreachable() {
|
||||||
let err = ImapError::Bad("server error".into());
|
let err = ImapError::Bad("server error".into());
|
||||||
|
|||||||
+8
-55
@@ -37,13 +37,6 @@ pub struct AccountSession {
|
|||||||
pub user_agent: String,
|
pub user_agent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thread-safe, in-memory session store backed by `DashMap`.
|
|
||||||
///
|
|
||||||
/// Shared across all Axum handlers via `Arc`. Sessions expire after
|
|
||||||
/// `timeout` of inactivity (sliding window).
|
|
||||||
///
|
|
||||||
/// Browser binding allows multiple accounts to be associated with a single
|
|
||||||
/// browser session, enabling account switching without re-authentication.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SessionStore {
|
pub struct SessionStore {
|
||||||
sessions: Arc<DashMap<SessionId, AccountSession>>,
|
sessions: Arc<DashMap<SessionId, AccountSession>>,
|
||||||
@@ -81,7 +74,10 @@ impl SessionStore {
|
|||||||
if let Some(value) = self.tls_verify_override {
|
if let Some(value) = self.tls_verify_override {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
let normalized = host.trim().trim_matches(|ch| ch == '[' || ch == ']').to_ascii_lowercase();
|
let normalized = host
|
||||||
|
.trim()
|
||||||
|
.trim_matches(|ch| ch == '[' || ch == ']')
|
||||||
|
.to_ascii_lowercase();
|
||||||
!matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1")
|
!matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +216,6 @@ impl SessionStore {
|
|||||||
|
|
||||||
pub fn remove_account(&self, account_id: &str) -> bool {
|
pub fn remove_account(&self, account_id: &str) -> bool {
|
||||||
if let Some((removed_account_id, session_id)) = self.account_to_session.remove(account_id) {
|
if let Some((removed_account_id, session_id)) = self.account_to_session.remove(account_id) {
|
||||||
// Remove from sessions; restore account_to_session on panic.
|
|
||||||
if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
self.sessions.remove(&session_id);
|
self.sessions.remove(&session_id);
|
||||||
})) {
|
})) {
|
||||||
@@ -229,7 +224,6 @@ impl SessionStore {
|
|||||||
std::panic::resume_unwind(payload);
|
std::panic::resume_unwind(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use reverse index for O(1) browser lookup instead of iterating.
|
|
||||||
if let Some((_, browser_id)) = self.account_to_browser.remove(account_id)
|
if let Some((_, browser_id)) = self.account_to_browser.remove(account_id)
|
||||||
&& let Some(mut entry) = self.browsers.get_mut(&browser_id)
|
&& let Some(mut entry) = self.browsers.get_mut(&browser_id)
|
||||||
&& let Some(pos) = entry.value().iter().position(|id| id == account_id)
|
&& let Some(pos) = entry.value().iter().position(|id| id == account_id)
|
||||||
@@ -242,8 +236,6 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store a display-only User-Agent string for active-session diagnostics.
|
|
||||||
/// It is never used for authentication or authorization decisions.
|
|
||||||
pub fn set_user_agent(&self, account_id: &str, user_agent: String) {
|
pub fn set_user_agent(&self, account_id: &str, user_agent: String) {
|
||||||
let Some(session_id) = self
|
let Some(session_id) = self
|
||||||
.account_to_session
|
.account_to_session
|
||||||
@@ -257,8 +249,6 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all currently live account sessions belonging to the same
|
|
||||||
/// RustBox user, including sessions created by other browsers.
|
|
||||||
pub fn get_user_sessions(&self, user_hash: &str) -> Vec<AccountSession> {
|
pub fn get_user_sessions(&self, user_hash: &str) -> Vec<AccountSession> {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut result: Vec<AccountSession> = self
|
let mut result: Vec<AccountSession> = self
|
||||||
@@ -280,7 +270,6 @@ impl SessionStore {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Revoke one session only when it belongs to the requested user.
|
|
||||||
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
|
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
|
||||||
let Some(session_id) = self
|
let Some(session_id) = self
|
||||||
.account_to_session
|
.account_to_session
|
||||||
@@ -299,7 +288,6 @@ impl SessionStore {
|
|||||||
self.remove_account(account_id)
|
self.remove_account(account_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Revoke every session for a user except the current account session.
|
|
||||||
pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize {
|
pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize {
|
||||||
let account_ids: Vec<String> = self
|
let account_ids: Vec<String> = self
|
||||||
.sessions
|
.sessions
|
||||||
@@ -321,10 +309,6 @@ impl SessionStore {
|
|||||||
|
|
||||||
pub fn remove_browser(&self, browser_id: &str) {
|
pub fn remove_browser(&self, browser_id: &str) {
|
||||||
if let Some((_, account_ids)) = self.browsers.remove(browser_id) {
|
if let Some((_, account_ids)) = self.browsers.remove(browser_id) {
|
||||||
// Clean up each account. Individual failures are tolerated --
|
|
||||||
// the browser entry is already gone, so the accounts become
|
|
||||||
// unreachable via browser lookups. They will be reaped by
|
|
||||||
// purge_expired or a subsequent remove_account call.
|
|
||||||
for account_id in account_ids {
|
for account_id in account_ids {
|
||||||
self.account_to_browser.remove(&account_id);
|
self.account_to_browser.remove(&account_id);
|
||||||
if let Some((_, session_id)) = self.account_to_session.remove(&account_id) {
|
if let Some((_, session_id)) = self.account_to_session.remove(&account_id) {
|
||||||
@@ -389,17 +373,13 @@ impl SessionStore {
|
|||||||
self.sessions.remove(token).is_some()
|
self.sessions.remove(token).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove an account's entries from `account_to_session` and `browsers`.
|
|
||||||
/// If a browser's account list becomes empty, the browser entry is removed
|
|
||||||
/// too -- keeping it would serve no purpose and would itself be a leak.
|
|
||||||
fn evict_account(&self, account_id: &str) {
|
fn evict_account(&self, account_id: &str) {
|
||||||
self.account_to_session.remove(account_id);
|
self.account_to_session.remove(account_id);
|
||||||
self.account_to_browser.remove(account_id);
|
self.account_to_browser.remove(account_id);
|
||||||
|
|
||||||
// We cannot predict which browser owns this account, so scan all.
|
|
||||||
self.browsers.retain(|_, accounts| {
|
self.browsers.retain(|_, accounts| {
|
||||||
accounts.retain(|id| id != account_id);
|
accounts.retain(|id| id != account_id);
|
||||||
// Keep the browser entry only if it still has accounts.
|
|
||||||
!accounts.is_empty()
|
!accounts.is_empty()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -408,7 +388,6 @@ impl SessionStore {
|
|||||||
pub fn purge_expired(&self) {
|
pub fn purge_expired(&self) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
// Collect expired account IDs while retaining live sessions.
|
|
||||||
let mut expired_accounts = Vec::new();
|
let mut expired_accounts = Vec::new();
|
||||||
self.sessions.retain(|_, state| {
|
self.sessions.retain(|_, state| {
|
||||||
let effective_timeout = state.timeout_override.unwrap_or(self.timeout);
|
let effective_timeout = state.timeout_override.unwrap_or(self.timeout);
|
||||||
@@ -424,14 +403,10 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify internal consistency across all four maps. Returns a list of
|
|
||||||
/// inconsistency descriptions. An empty vec means the store is consistent.
|
|
||||||
/// Intended for testing and diagnostics.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn check_consistency(&self) -> Vec<String> {
|
pub fn check_consistency(&self) -> Vec<String> {
|
||||||
let mut issues = Vec::new();
|
let mut issues = Vec::new();
|
||||||
|
|
||||||
// Every session's account_id should have a matching account_to_session entry.
|
|
||||||
for entry in self.sessions.iter() {
|
for entry in self.sessions.iter() {
|
||||||
let token = entry.key();
|
let token = entry.key();
|
||||||
let account_id = &entry.value().account_id;
|
let account_id = &entry.value().account_id;
|
||||||
@@ -451,7 +426,6 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every account_to_session entry should point to an existing session.
|
|
||||||
for entry in self.account_to_session.iter() {
|
for entry in self.account_to_session.iter() {
|
||||||
let account_id = entry.key();
|
let account_id = entry.key();
|
||||||
let session_id = entry.value();
|
let session_id = entry.value();
|
||||||
@@ -462,7 +436,6 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every account_id in a browser list should exist in account_to_session.
|
|
||||||
for entry in self.browsers.iter() {
|
for entry in self.browsers.iter() {
|
||||||
let browser_id = entry.key();
|
let browser_id = entry.key();
|
||||||
for account_id in entry.value().iter() {
|
for account_id in entry.value().iter() {
|
||||||
@@ -474,7 +447,6 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every account_to_browser entry should reference a valid account and browser.
|
|
||||||
for entry in self.account_to_browser.iter() {
|
for entry in self.account_to_browser.iter() {
|
||||||
let account_id = entry.key();
|
let account_id = entry.key();
|
||||||
let browser_id = entry.value();
|
let browser_id = entry.value();
|
||||||
@@ -878,8 +850,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sliding_window_refreshes_expiry() {
|
fn sliding_window_refreshes_expiry() {
|
||||||
// Use generous margins so the test is reliable under CI / Nix sandbox
|
|
||||||
// CPU pressure where sleeps can overshoot significantly.
|
|
||||||
let store = SessionStore::new(Duration::from_millis(800));
|
let store = SessionStore::new(Duration::from_millis(800));
|
||||||
let browser_id = store.create_browser();
|
let browser_id = store.create_browser();
|
||||||
|
|
||||||
@@ -911,8 +881,6 @@ mod tests {
|
|||||||
assert_eq!(session.imap_port, 993);
|
assert_eq!(session.imap_port, 993);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Consistency tests ---
|
|
||||||
|
|
||||||
fn add_test_account(
|
fn add_test_account(
|
||||||
store: &SessionStore,
|
store: &SessionStore,
|
||||||
browser_id: &str,
|
browser_id: &str,
|
||||||
@@ -996,26 +964,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(store.sessions.len(), 0);
|
assert_eq!(store.sessions.len(), 0);
|
||||||
assert_eq!(store.account_to_session.len(), 0);
|
assert_eq!(store.account_to_session.len(), 0);
|
||||||
// Browser entry is removed when its account list becomes empty.
|
|
||||||
assert!(!store.browser_exists(&browser));
|
assert!(!store.browser_exists(&browser));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn purge_expired_respects_timeout_override() {
|
fn purge_expired_respects_timeout_override() {
|
||||||
let store = short_lived_store(); // 50ms default
|
let store = short_lived_store();
|
||||||
let browser = store.create_browser();
|
let browser = store.create_browser();
|
||||||
|
|
||||||
// Add one account with a long timeout override
|
|
||||||
let (token, account_id) = add_test_account(&store, &browser, "long@example.com");
|
let (token, account_id) = add_test_account(&store, &browser, "long@example.com");
|
||||||
store.sessions.get_mut(&token).unwrap().timeout_override = Some(Duration::from_secs(3600));
|
store.sessions.get_mut(&token).unwrap().timeout_override = Some(Duration::from_secs(3600));
|
||||||
|
|
||||||
// Add one with default (short) timeout
|
|
||||||
add_test_account(&store, &browser, "short@example.com");
|
add_test_account(&store, &browser, "short@example.com");
|
||||||
|
|
||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
store.purge_expired();
|
store.purge_expired();
|
||||||
|
|
||||||
// The long-timeout session should survive.
|
|
||||||
assert!(store.get(&token).is_some());
|
assert!(store.get(&token).is_some());
|
||||||
assert!(store.account_to_session.contains_key(&account_id));
|
assert!(store.account_to_session.contains_key(&account_id));
|
||||||
assert_eq!(store.sessions.len(), 1);
|
assert_eq!(store.sessions.len(), 1);
|
||||||
@@ -1051,18 +1016,15 @@ mod tests {
|
|||||||
issues.is_empty(),
|
issues.is_empty(),
|
||||||
"store should be consistent after mixed operations, got: {issues:?}"
|
"store should be consistent after mixed operations, got: {issues:?}"
|
||||||
);
|
);
|
||||||
// Only b@example.com should remain.
|
|
||||||
assert_eq!(store.sessions.len(), 1);
|
assert_eq!(store.sessions.len(), 1);
|
||||||
assert_eq!(store.account_to_session.len(), 1);
|
assert_eq!(store.account_to_session.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Reverse index tests ---
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remove_account_uses_reverse_index() {
|
fn remove_account_uses_reverse_index() {
|
||||||
let store = long_lived_store();
|
let store = long_lived_store();
|
||||||
|
|
||||||
// Create many browsers to demonstrate the O(1) lookup
|
|
||||||
let mut other_browsers = Vec::new();
|
let mut other_browsers = Vec::new();
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
let bid = store.create_browser();
|
let bid = store.create_browser();
|
||||||
@@ -1095,23 +1057,19 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify reverse index was populated
|
|
||||||
assert!(store.account_to_browser.contains_key(&account_id));
|
assert!(store.account_to_browser.contains_key(&account_id));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.account_to_browser.get(&account_id).unwrap().value(),
|
store.account_to_browser.get(&account_id).unwrap().value(),
|
||||||
&target_browser
|
&target_browser
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove the account -- uses O(1) reverse index, not O(n) iteration
|
|
||||||
assert!(store.remove_account(&account_id));
|
assert!(store.remove_account(&account_id));
|
||||||
|
|
||||||
// Verify full cleanup
|
|
||||||
assert!(store.get(&token).is_none());
|
assert!(store.get(&token).is_none());
|
||||||
assert!(!store.account_to_session.contains_key(&account_id));
|
assert!(!store.account_to_session.contains_key(&account_id));
|
||||||
assert!(!store.account_to_browser.contains_key(&account_id));
|
assert!(!store.account_to_browser.contains_key(&account_id));
|
||||||
assert!(store.get_browser_accounts(&target_browser).is_empty());
|
assert!(store.get_browser_accounts(&target_browser).is_empty());
|
||||||
|
|
||||||
// Other browsers are untouched
|
|
||||||
for bid in &other_browsers {
|
for bid in &other_browsers {
|
||||||
assert_eq!(store.get_browser_accounts(bid).len(), 1);
|
assert_eq!(store.get_browser_accounts(bid).len(), 1);
|
||||||
}
|
}
|
||||||
@@ -1184,8 +1142,6 @@ mod tests {
|
|||||||
assert!(store.get_browser_accounts(&browser_id).is_empty());
|
assert!(store.get_browser_accounts(&browser_id).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cascade eviction tests ---
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn purge_expired_cascades_to_browsers_and_account_map() {
|
fn purge_expired_cascades_to_browsers_and_account_map() {
|
||||||
let store = short_lived_store();
|
let store = short_lived_store();
|
||||||
@@ -1224,7 +1180,6 @@ mod tests {
|
|||||||
let store = SessionStore::new(Duration::from_millis(150));
|
let store = SessionStore::new(Duration::from_millis(150));
|
||||||
let browser_id = store.create_browser();
|
let browser_id = store.create_browser();
|
||||||
|
|
||||||
// First account -- will expire.
|
|
||||||
let (_token1, account1) = store.add_account_to_browser(
|
let (_token1, account1) = store.add_account_to_browser(
|
||||||
&browser_id,
|
&browser_id,
|
||||||
"old@example.com".into(),
|
"old@example.com".into(),
|
||||||
@@ -1240,7 +1195,6 @@ mod tests {
|
|||||||
|
|
||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
|
||||||
// Second account -- added later, still alive after sleep.
|
|
||||||
let (_token2, account2) = store.add_account_to_browser(
|
let (_token2, account2) = store.add_account_to_browser(
|
||||||
&browser_id,
|
&browser_id,
|
||||||
"new@example.com".into(),
|
"new@example.com".into(),
|
||||||
@@ -1297,7 +1251,6 @@ mod tests {
|
|||||||
|
|
||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
|
||||||
// Accessing the expired session should trigger cascade cleanup.
|
|
||||||
assert!(store.get(&token).is_none());
|
assert!(store.get(&token).is_none());
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+2
-16
@@ -8,29 +8,17 @@ mod embedded {
|
|||||||
embed_migrations!("migrations");
|
embed_migrations!("migrations");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute a SHA-256 hash of the given email address and return it as a
|
|
||||||
/// lowercase hex-encoded string (64 characters).
|
|
||||||
pub fn hash_email(email: &str) -> String {
|
pub fn hash_email(email: &str) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(email.as_bytes());
|
hasher.update(email.as_bytes());
|
||||||
let result = hasher.finalize();
|
let result = hasher.finalize();
|
||||||
// Format each byte as two lowercase hex digits.
|
|
||||||
result.iter().map(|b| format!("{b:02x}")).collect()
|
result.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provision a per-user data directory under `data_dir`.
|
|
||||||
///
|
|
||||||
/// Creates `{data_dir}/{user_hash}/` with a `tantivy/` subdirectory,
|
|
||||||
/// opens (or creates) `db.sqlite`, and runs refinery migrations on it.
|
|
||||||
///
|
|
||||||
/// This function is idempotent: calling it multiple times with the same
|
|
||||||
/// arguments is safe and will not produce errors.
|
|
||||||
///
|
|
||||||
/// Returns the path to the user directory on success.
|
|
||||||
pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result<PathBuf, String> {
|
pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result<PathBuf, String> {
|
||||||
let user_dir = PathBuf::from(data_dir).join(user_hash);
|
let user_dir = PathBuf::from(data_dir).join(user_hash);
|
||||||
|
|
||||||
// Create the user directory and the tantivy subdirectory.
|
|
||||||
let tantivy_dir = user_dir.join("tantivy");
|
let tantivy_dir = user_dir.join("tantivy");
|
||||||
fs::create_dir_all(&tantivy_dir).map_err(|e| format!("failed to create directories: {e}"))?;
|
fs::create_dir_all(&tantivy_dir).map_err(|e| format!("failed to create directories: {e}"))?;
|
||||||
|
|
||||||
@@ -38,17 +26,15 @@ pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result<PathBuf, S
|
|||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
if let Ok(mut perms) = fs::metadata(&user_dir).map(|m| m.permissions()) {
|
if let Ok(mut perms) = fs::metadata(&user_dir).map(|m| m.permissions()) {
|
||||||
perms.set_mode(0o700); // rwx------
|
perms.set_mode(0o700);
|
||||||
let _ = fs::set_permissions(&user_dir, perms);
|
let _ = fs::set_permissions(&user_dir, perms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open (or create) the SQLite database.
|
|
||||||
let db_path = user_dir.join("db.sqlite");
|
let db_path = user_dir.join("db.sqlite");
|
||||||
let mut conn = rusqlite::Connection::open(&db_path)
|
let mut conn = rusqlite::Connection::open(&db_path)
|
||||||
.map_err(|e| format!("failed to open database: {e}"))?;
|
.map_err(|e| format!("failed to open database: {e}"))?;
|
||||||
|
|
||||||
// Run refinery migrations.
|
|
||||||
embedded::migrations::runner()
|
embedded::migrations::runner()
|
||||||
.run(&mut conn)
|
.run(&mut conn)
|
||||||
.map_err(|e| format!("failed to run migrations: {e}"))?;
|
.map_err(|e| format!("failed to run migrations: {e}"))?;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use std::io::BufReader;
|
|||||||
|
|
||||||
use crate::db::calendar::CreateEvent;
|
use crate::db::calendar::CreateEvent;
|
||||||
|
|
||||||
/// Parse iCalendar text and return a list of events ready for database insertion.
|
|
||||||
pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> {
|
pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> {
|
||||||
let reader = BufReader::new(ics_text.as_bytes());
|
let reader = BufReader::new(ics_text.as_bytes());
|
||||||
let parser = IcalParser::new(reader);
|
let parser = IcalParser::new(reader);
|
||||||
@@ -72,13 +71,11 @@ pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> {
|
|||||||
title = "(No title)".to_string();
|
title = "(No title)".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If end_time is empty, default to start_time + 1 hour (or same day for all-day)
|
|
||||||
if end_time.is_empty() {
|
if end_time.is_empty() {
|
||||||
end_time = start_time.clone();
|
end_time = start_time.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
if start_time.is_empty() {
|
if start_time.is_empty() {
|
||||||
// Skip events without a start time
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,16 +107,11 @@ pub fn parse_ics(ics_text: &str) -> Result<Vec<CreateEvent>, String> {
|
|||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a DTSTART/DTEND value is date-only (no time component).
|
|
||||||
fn is_date_only(value: &str) -> bool {
|
fn is_date_only(value: &str) -> bool {
|
||||||
// Date-only values are 8 characters: YYYYMMDD
|
|
||||||
// Date-time values are at least 15: YYYYMMDDTHHmmSS
|
|
||||||
value.len() == 8 && value.chars().all(|c| c.is_ascii_digit())
|
value.len() == 8 && value.chars().all(|c| c.is_ascii_digit())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an iCalendar date/datetime value to ISO 8601 format.
|
|
||||||
fn parse_ical_datetime(value: &str, params: &Option<Vec<(String, Vec<String>)>>) -> String {
|
fn parse_ical_datetime(value: &str, params: &Option<Vec<(String, Vec<String>)>>) -> String {
|
||||||
// Check for TZID parameter
|
|
||||||
let _tzid = params.as_ref().and_then(|ps| {
|
let _tzid = params.as_ref().and_then(|ps| {
|
||||||
ps.iter()
|
ps.iter()
|
||||||
.find(|(k, _)| k == "TZID")
|
.find(|(k, _)| k == "TZID")
|
||||||
@@ -130,12 +122,10 @@ fn parse_ical_datetime(value: &str, params: &Option<Vec<(String, Vec<String>)>>)
|
|||||||
let value = value.trim();
|
let value = value.trim();
|
||||||
|
|
||||||
if value.len() == 8 && value.chars().all(|c| c.is_ascii_digit()) {
|
if value.len() == 8 && value.chars().all(|c| c.is_ascii_digit()) {
|
||||||
// Date only: YYYYMMDD -> YYYY-MM-DD
|
|
||||||
return format!("{}-{}-{}", &value[0..4], &value[4..6], &value[6..8]);
|
return format!("{}-{}-{}", &value[0..4], &value[4..6], &value[6..8]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if value.len() >= 15 && value.contains('T') {
|
if value.len() >= 15 && value.contains('T') {
|
||||||
// DateTime: YYYYMMDDTHHmmSS or YYYYMMDDTHHmmSSZ
|
|
||||||
let date_part = &value[0..8];
|
let date_part = &value[0..8];
|
||||||
let time_part = &value[9..];
|
let time_part = &value[9..];
|
||||||
|
|
||||||
@@ -157,11 +147,9 @@ fn parse_ical_datetime(value: &str, params: &Option<Vec<(String, Vec<String>)>>)
|
|||||||
return format!("{year}-{month}-{day}T{hour}:{min}:{sec}{suffix}");
|
return format!("{year}-{month}-{day}T{hour}:{min}:{sec}{suffix}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return as-is
|
|
||||||
value.to_string()
|
value.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract email address from a cal-address value (e.g., "mailto:user@example.com").
|
|
||||||
fn extract_email_from_cal_address(value: &str) -> String {
|
fn extract_email_from_cal_address(value: &str) -> String {
|
||||||
if let Some(stripped) = value.strip_prefix("mailto:") {
|
if let Some(stripped) = value.strip_prefix("mailto:") {
|
||||||
stripped.to_string()
|
stripped.to_string()
|
||||||
|
|||||||
@@ -7,146 +7,102 @@ use figment::{
|
|||||||
use ipnet::IpNet;
|
use ipnet::IpNet;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
/// Application configuration loaded via figment.
|
|
||||||
///
|
|
||||||
/// Layers (lowest to highest priority):
|
|
||||||
/// 1. Serde defaults
|
|
||||||
/// 2. Optional TOML (`RUSTBOX_CONFIG`, default `/etc/rustbox/rustbox.toml`)
|
|
||||||
/// 3. Environment variables (flat, e.g. `PORT`, `IMAP_HOST`)
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
/// Bind address for the HTTP server.
|
|
||||||
#[serde(default = "default_host")]
|
#[serde(default = "default_host")]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
|
|
||||||
/// Port for the HTTP server.
|
|
||||||
#[serde(default = "default_port")]
|
#[serde(default = "default_port")]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
|
||||||
/// IMAP server hostname. Defaults to localhost when omitted.
|
|
||||||
#[serde(default = "default_imap_host")]
|
#[serde(default = "default_imap_host")]
|
||||||
pub imap_host: Option<String>,
|
pub imap_host: Option<String>,
|
||||||
|
|
||||||
/// IMAP server port.
|
|
||||||
#[serde(default = "default_imap_port")]
|
#[serde(default = "default_imap_port")]
|
||||||
pub imap_port: u16,
|
pub imap_port: u16,
|
||||||
|
|
||||||
/// SMTP server hostname. When unset or blank, IMAP host is used.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub smtp_host: Option<String>,
|
pub smtp_host: Option<String>,
|
||||||
|
|
||||||
/// SMTP server port.
|
|
||||||
#[serde(default = "default_smtp_port")]
|
#[serde(default = "default_smtp_port")]
|
||||||
pub smtp_port: u16,
|
pub smtp_port: u16,
|
||||||
|
|
||||||
/// Whether TLS is enabled for mail connections.
|
|
||||||
#[serde(default = "default_tls_enabled")]
|
#[serde(default = "default_tls_enabled")]
|
||||||
pub tls_enabled: bool,
|
pub tls_enabled: bool,
|
||||||
|
|
||||||
/// Optional certificate/hostname verification override for IMAP and SMTP.
|
|
||||||
/// When omitted, verification is disabled only for localhost/loopback endpoints
|
|
||||||
/// and enabled for remote hosts.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tls_verify: Option<bool>,
|
pub tls_verify: Option<bool>,
|
||||||
|
|
||||||
/// Maximum size of one uploaded compose attachment, in MiB.
|
|
||||||
#[serde(default = "default_max_attachment_size_mb")]
|
#[serde(default = "default_max_attachment_size_mb")]
|
||||||
pub max_attachment_size_mb: usize,
|
pub max_attachment_size_mb: usize,
|
||||||
|
|
||||||
/// Maximum size of the complete RFC822/MIME message submitted to the MTA, in MiB.
|
|
||||||
#[serde(default = "default_max_message_size_mb")]
|
#[serde(default = "default_max_message_size_mb")]
|
||||||
pub max_message_size_mb: usize,
|
pub max_message_size_mb: usize,
|
||||||
|
|
||||||
/// Directory for persistent data storage.
|
|
||||||
#[serde(default = "default_data_dir")]
|
#[serde(default = "default_data_dir")]
|
||||||
pub data_dir: String,
|
pub data_dir: String,
|
||||||
|
|
||||||
/// Session timeout in hours.
|
|
||||||
#[serde(default = "default_session_timeout_hours")]
|
#[serde(default = "default_session_timeout_hours")]
|
||||||
pub session_timeout_hours: u64,
|
pub session_timeout_hours: u64,
|
||||||
|
|
||||||
/// Whether users may request a persistent "remember me" session.
|
|
||||||
#[serde(default = "default_remember_me_enabled")]
|
#[serde(default = "default_remember_me_enabled")]
|
||||||
pub remember_me_enabled: bool,
|
pub remember_me_enabled: bool,
|
||||||
|
|
||||||
/// Lifetime of a remembered session in days.
|
|
||||||
#[serde(default = "default_remember_me_days")]
|
#[serde(default = "default_remember_me_days")]
|
||||||
pub remember_me_days: u64,
|
pub remember_me_days: u64,
|
||||||
|
|
||||||
/// Environment the application is running in (development, production)
|
|
||||||
#[serde(default = "default_environment")]
|
#[serde(default = "default_environment")]
|
||||||
pub environment: String,
|
pub environment: String,
|
||||||
|
|
||||||
/// Server-wide light/dark theme used before authentication (including the login screen).
|
|
||||||
/// Authenticated per-user display preferences still take precedence.
|
|
||||||
#[serde(default = "default_theme")]
|
#[serde(default = "default_theme")]
|
||||||
pub default_theme: String,
|
pub default_theme: String,
|
||||||
|
|
||||||
/// Server-wide interface style used on the login screen and by users whose
|
|
||||||
/// interface style is set to `default`. Examples: `gmail`, `proton`, `zoho`.
|
|
||||||
#[serde(default = "default_interface_skin")]
|
#[serde(default = "default_interface_skin")]
|
||||||
pub default_interface_skin: String,
|
pub default_interface_skin: String,
|
||||||
|
|
||||||
/// Optional base path prefix (e.g. "/mail") for serving behind a reverse proxy subpath.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub base_path: Option<String>,
|
pub base_path: Option<String>,
|
||||||
|
|
||||||
/// ManageSieve host. When unset, the authenticated account's IMAP host is used.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sieve_host: Option<String>,
|
pub sieve_host: Option<String>,
|
||||||
|
|
||||||
/// ManageSieve TCP port.
|
|
||||||
#[serde(default = "default_sieve_port")]
|
#[serde(default = "default_sieve_port")]
|
||||||
pub sieve_port: u16,
|
pub sieve_port: u16,
|
||||||
|
|
||||||
/// ManageSieve transport: `starttls`, `implicit` or `none`.
|
|
||||||
#[serde(default = "default_sieve_tls_mode")]
|
#[serde(default = "default_sieve_tls_mode")]
|
||||||
pub sieve_tls_mode: String,
|
pub sieve_tls_mode: String,
|
||||||
|
|
||||||
/// Explicit opt-in for sending reusable credentials over a cleartext Sieve connection.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sieve_allow_insecure_auth: bool,
|
pub sieve_allow_insecure_auth: bool,
|
||||||
|
|
||||||
/// Network timeout for ManageSieve connect/read/write operations.
|
|
||||||
#[serde(default = "default_sieve_timeout_secs")]
|
#[serde(default = "default_sieve_timeout_secs")]
|
||||||
pub sieve_timeout_secs: u64,
|
pub sieve_timeout_secs: u64,
|
||||||
|
|
||||||
/// Comma-separated list of trusted proxy IPs or CIDR ranges that are allowed to set X-Forwarded-For.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trusted_proxies: Option<String>,
|
pub trusted_proxies: Option<String>,
|
||||||
|
|
||||||
/// Administrator-defined extra headers added to every outgoing message.
|
|
||||||
/// Each entry uses `Header-Name: value`. Structural/MIME headers are rejected.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub outgoing_headers: Vec<String>,
|
pub outgoing_headers: Vec<String>,
|
||||||
|
|
||||||
/// Enable Web Key Directory lookups for OpenPGP recipient keys.
|
|
||||||
#[serde(default = "default_pgp_wkd_enabled")]
|
#[serde(default = "default_pgp_wkd_enabled")]
|
||||||
pub pgp_wkd_enabled: bool,
|
pub pgp_wkd_enabled: bool,
|
||||||
|
|
||||||
/// Allow optional HKP-over-HTTPS fallback for OpenPGP discovery.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pgp_hkp_enabled: bool,
|
pub pgp_hkp_enabled: bool,
|
||||||
|
|
||||||
/// HKP-compatible HTTPS endpoint used when the fallback is enabled.
|
|
||||||
#[serde(default = "default_pgp_hkp_url")]
|
#[serde(default = "default_pgp_hkp_url")]
|
||||||
pub pgp_hkp_url: String,
|
pub pgp_hkp_url: String,
|
||||||
|
|
||||||
/// Timeout for WKD/HKP network operations.
|
|
||||||
#[serde(default = "default_pgp_network_timeout_secs")]
|
#[serde(default = "default_pgp_network_timeout_secs")]
|
||||||
pub pgp_network_timeout_secs: u64,
|
pub pgp_network_timeout_secs: u64,
|
||||||
|
|
||||||
/// Maximum accepted OpenPGP key response size.
|
|
||||||
#[serde(default = "default_pgp_max_key_bytes")]
|
#[serde(default = "default_pgp_max_key_bytes")]
|
||||||
pub pgp_max_key_bytes: usize,
|
pub pgp_max_key_bytes: usize,
|
||||||
|
|
||||||
/// Allow authenticated users to export private OpenPGP key material.
|
|
||||||
/// Disabled by default; public-key export is always available.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pgp_allow_private_export: bool,
|
pub pgp_allow_private_export: bool,
|
||||||
|
|
||||||
/// Administrator defaults for special IMAP folders. Per-account settings override these.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub default_sent_folder: Option<String>,
|
pub default_sent_folder: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -302,7 +258,6 @@ impl Default for AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
/// Use the root path when no reverse-proxy prefix is configured.
|
|
||||||
pub fn normalized_base_path(&self) -> &str {
|
pub fn normalized_base_path(&self) -> &str {
|
||||||
self.base_path
|
self.base_path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -310,8 +265,6 @@ impl AppConfig {
|
|||||||
.unwrap_or("/")
|
.unwrap_or("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Certificate verification policy for a configured mail host. Explicit
|
|
||||||
/// `tls_verify` wins. Without it, only localhost/loopback skips validation.
|
|
||||||
pub fn tls_verify_for_host(&self, host: &str) -> bool {
|
pub fn tls_verify_for_host(&self, host: &str) -> bool {
|
||||||
if let Some(value) = self.tls_verify {
|
if let Some(value) = self.tls_verify {
|
||||||
return value;
|
return value;
|
||||||
@@ -331,8 +284,6 @@ impl AppConfig {
|
|||||||
self.max_message_size_mb.saturating_mul(1024 * 1024)
|
self.max_message_size_mb.saturating_mul(1024 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the `trusted_proxies` field into IP or CIDR entries.
|
|
||||||
/// Logs warnings for unparseable entries.
|
|
||||||
pub fn parsed_trusted_proxies(&self) -> Vec<TrustedProxy> {
|
pub fn parsed_trusted_proxies(&self) -> Vec<TrustedProxy> {
|
||||||
let Some(ref proxies) = self.trusted_proxies else {
|
let Some(ref proxies) = self.trusted_proxies else {
|
||||||
return vec![];
|
return vec![];
|
||||||
@@ -409,10 +360,6 @@ impl AppConfig {
|
|||||||
Ok(parsed)
|
Ok(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load configuration by layering serde defaults, optional TOML, and environment variables.
|
|
||||||
///
|
|
||||||
/// Environment variables are read without a prefix and mapped directly to
|
|
||||||
/// struct fields via case-insensitive matching (e.g. `IMAP_HOST` → `imap_host`).
|
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub fn load() -> Result<Self, figment::Error> {
|
pub fn load() -> Result<Self, figment::Error> {
|
||||||
let path = std::env::var("RUSTBOX_CONFIG")
|
let path = std::env::var("RUSTBOX_CONFIG")
|
||||||
@@ -423,8 +370,6 @@ impl AppConfig {
|
|||||||
figment = figment.merge(Toml::file(path));
|
figment = figment.merge(Toml::file(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep Oxi-compatible flat environment names (IMAP_HOST, SMTP_HOST, ...).
|
|
||||||
// Environment variables intentionally override the TOML file.
|
|
||||||
figment.merge(Env::raw()).extract()
|
figment.merge(Env::raw()).extract()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,8 +460,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_config_values_load_correctly() {
|
fn default_config_values_load_correctly() {
|
||||||
// With no env vars set, all defaults should apply.
|
|
||||||
// We use figment with no providers (only serde defaults).
|
|
||||||
let config: AppConfig = Figment::new().extract().expect("defaults should load");
|
let config: AppConfig = Figment::new().extract().expect("defaults should load");
|
||||||
|
|
||||||
assert_eq!(config.host, "0.0.0.0");
|
assert_eq!(config.host, "0.0.0.0");
|
||||||
@@ -555,8 +498,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_var_overrides_work() {
|
fn env_var_overrides_work() {
|
||||||
// Simulate env overrides via figment's tuple provider
|
|
||||||
// to verify all fields are overridable.
|
|
||||||
let config: AppConfig = Figment::new()
|
let config: AppConfig = Figment::new()
|
||||||
.merge(("host", "127.0.0.1"))
|
.merge(("host", "127.0.0.1"))
|
||||||
.merge(("port", 8080u16))
|
.merge(("port", 8080u16))
|
||||||
@@ -611,10 +552,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn real_env_vars_override_multi_word_fields() {
|
fn real_env_vars_override_multi_word_fields() {
|
||||||
// Verify that actual environment variables with underscores
|
|
||||||
// (e.g. IMAP_HOST) correctly map to struct fields (imap_host).
|
|
||||||
// SAFETY: This test runs single-threaded (--test-threads=1)
|
|
||||||
// and cleans up env vars after use.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("IMAP_HOST", "test-imap.example.com");
|
std::env::set_var("IMAP_HOST", "test-imap.example.com");
|
||||||
std::env::set_var("DATA_DIR", "/tmp/oxi-test");
|
std::env::set_var("DATA_DIR", "/tmp/oxi-test");
|
||||||
@@ -625,7 +562,6 @@ mod tests {
|
|||||||
assert_eq!(config.imap_host.as_deref(), Some("test-imap.example.com"));
|
assert_eq!(config.imap_host.as_deref(), Some("test-imap.example.com"));
|
||||||
assert_eq!(config.data_dir, "/tmp/oxi-test");
|
assert_eq!(config.data_dir, "/tmp/oxi-test");
|
||||||
|
|
||||||
// Clean up
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("IMAP_HOST");
|
std::env::remove_var("IMAP_HOST");
|
||||||
std::env::remove_var("DATA_DIR");
|
std::env::remove_var("DATA_DIR");
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ use rusqlite::{Connection, params};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Structs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// A calendar event record, mirroring the `calendar_events` table.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct CalendarEvent {
|
pub struct CalendarEvent {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -31,7 +26,6 @@ pub struct CalendarEvent {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fields for creating a new calendar event.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateEvent {
|
pub struct CreateEvent {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -72,7 +66,6 @@ fn default_source() -> String {
|
|||||||
"manual".to_string()
|
"manual".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fields for updating an existing calendar event. All optional.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEvent {
|
pub struct UpdateEvent {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
@@ -90,7 +83,6 @@ pub struct UpdateEvent {
|
|||||||
pub reminder_minutes: Option<i64>,
|
pub reminder_minutes: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calendar settings record, mirroring the `calendar_settings` table.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct CalendarSettings {
|
pub struct CalendarSettings {
|
||||||
pub default_view: String,
|
pub default_view: String,
|
||||||
@@ -107,7 +99,6 @@ pub struct CalendarSettings {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fields for updating calendar settings. All optional.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateCalendarSettings {
|
pub struct UpdateCalendarSettings {
|
||||||
pub default_view: Option<String>,
|
pub default_view: Option<String>,
|
||||||
@@ -124,7 +115,6 @@ pub struct UpdateCalendarSettings {
|
|||||||
pub caldav_enabled: Option<bool>,
|
pub caldav_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A meeting template record.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MeetingTemplate {
|
pub struct MeetingTemplate {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -135,10 +125,6 @@ pub struct MeetingTemplate {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Row mappers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
|
const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
|
||||||
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
|
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
|
||||||
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
|
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
|
||||||
@@ -200,11 +186,6 @@ fn row_to_template(row: &rusqlite::Row<'_>) -> rusqlite::Result<MeetingTemplate>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API — Events
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// List events in the given time range (inclusive start, inclusive end).
|
|
||||||
pub fn list_events(
|
pub fn list_events(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
start: &str,
|
start: &str,
|
||||||
@@ -229,7 +210,6 @@ pub fn list_events(
|
|||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all calendar events ordered by start time.
|
|
||||||
pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String> {
|
pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String> {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC"
|
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC"
|
||||||
@@ -248,7 +228,6 @@ pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String>
|
|||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a single event by ID.
|
|
||||||
pub fn get_event(conn: &Connection, id: &str) -> Result<Option<CalendarEvent>, String> {
|
pub fn get_event(conn: &Connection, id: &str) -> Result<Option<CalendarEvent>, String> {
|
||||||
let sql = format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1");
|
let sql = format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE id = ?1");
|
||||||
let result = conn.query_row(&sql, params![id], row_to_event);
|
let result = conn.query_row(&sql, params![id], row_to_event);
|
||||||
@@ -260,7 +239,6 @@ pub fn get_event(conn: &Connection, id: &str) -> Result<Option<CalendarEvent>, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new calendar event. Generates a UUID for the ID.
|
|
||||||
pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEvent, String> {
|
pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEvent, String> {
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let now: String = conn
|
let now: String = conn
|
||||||
@@ -302,13 +280,11 @@ pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEve
|
|||||||
get_event(conn, &id)?.ok_or_else(|| "Event not found after insert".to_string())
|
get_event(conn, &id)?.ok_or_else(|| "Event not found after insert".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update an existing calendar event. Returns the updated event, or None if not found.
|
|
||||||
pub fn update_event(
|
pub fn update_event(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
id: &str,
|
id: &str,
|
||||||
data: &UpdateEvent,
|
data: &UpdateEvent,
|
||||||
) -> Result<Option<CalendarEvent>, String> {
|
) -> Result<Option<CalendarEvent>, String> {
|
||||||
// Build SET clauses dynamically for non-None fields.
|
|
||||||
let mut sets = Vec::new();
|
let mut sets = Vec::new();
|
||||||
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||||
let mut idx = 1u32;
|
let mut idx = 1u32;
|
||||||
@@ -371,7 +347,6 @@ pub fn update_event(
|
|||||||
get_event(conn, id)
|
get_event(conn, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an event by ID. Returns true if a row was deleted.
|
|
||||||
pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> {
|
pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute("DELETE FROM calendar_events WHERE id = ?1", params![id])
|
.execute("DELETE FROM calendar_events WHERE id = ?1", params![id])
|
||||||
@@ -379,7 +354,6 @@ pub fn delete_event(conn: &Connection, id: &str) -> Result<bool, String> {
|
|||||||
Ok(deleted > 0)
|
Ok(deleted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find an event by its source_uid (for deduplication during ICS import).
|
|
||||||
pub fn find_event_by_source_uid(
|
pub fn find_event_by_source_uid(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
source_uid: &str,
|
source_uid: &str,
|
||||||
@@ -394,17 +368,11 @@ pub fn find_event_by_source_uid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API — Settings
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
|
const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
|
||||||
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
|
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
|
||||||
caldav_username, caldav_enabled, updated_at";
|
caldav_username, caldav_enabled, updated_at";
|
||||||
|
|
||||||
/// Get calendar settings, inserting a default row if none exists.
|
|
||||||
pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, String> {
|
pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, String> {
|
||||||
// Ensure the singleton row exists.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
|
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
|
||||||
[],
|
[],
|
||||||
@@ -416,12 +384,10 @@ pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, Stri
|
|||||||
.map_err(|e| format!("Failed to get calendar settings: {e}"))
|
.map_err(|e| format!("Failed to get calendar settings: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update calendar settings. Returns the updated settings.
|
|
||||||
pub fn update_calendar_settings(
|
pub fn update_calendar_settings(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data: &UpdateCalendarSettings,
|
data: &UpdateCalendarSettings,
|
||||||
) -> Result<CalendarSettings, String> {
|
) -> Result<CalendarSettings, String> {
|
||||||
// Ensure the singleton row exists.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
|
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
|
||||||
[],
|
[],
|
||||||
@@ -483,11 +449,6 @@ pub fn update_calendar_settings(
|
|||||||
get_calendar_settings(conn)
|
get_calendar_settings(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API — Meeting Templates
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// List all meeting templates.
|
|
||||||
pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>, String> {
|
pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -506,7 +467,6 @@ pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>,
|
|||||||
Ok(templates)
|
Ok(templates)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new meeting template.
|
|
||||||
pub fn create_meeting_template(
|
pub fn create_meeting_template(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -526,7 +486,6 @@ pub fn create_meeting_template(
|
|||||||
.map_err(|e| format!("Failed to read created template: {e}"))
|
.map_err(|e| format!("Failed to read created template: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a meeting template by ID. Returns true if a row was deleted.
|
|
||||||
pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, String> {
|
pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, String> {
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute("DELETE FROM meeting_templates WHERE id = ?1", params![id])
|
.execute("DELETE FROM meeting_templates WHERE id = ?1", params![id])
|
||||||
@@ -534,10 +493,6 @@ pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, Strin
|
|||||||
Ok(deleted > 0)
|
Ok(deleted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -773,7 +728,6 @@ mod tests {
|
|||||||
assert_eq!(all.len(), 0);
|
assert_eq!(all.len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create CreateEvent variants without full Clone impl
|
|
||||||
impl CreateEvent {
|
impl CreateEvent {
|
||||||
fn clone_fields(&self) -> Self {
|
fn clone_fields(&self) -> Self {
|
||||||
CreateEvent {
|
CreateEvent {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use serde::Serialize;
|
|||||||
|
|
||||||
use crate::db::contacts::Contact;
|
use crate::db::contacts::Contact;
|
||||||
|
|
||||||
/// A contact group record, mirroring the `contact_groups` table.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ContactGroup {
|
pub struct ContactGroup {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -13,11 +12,6 @@ pub struct ContactGroup {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Create a new contact group.
|
|
||||||
pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), String> {
|
pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO contact_groups (id, name) VALUES (?1, ?2)",
|
"INSERT INTO contact_groups (id, name) VALUES (?1, ?2)",
|
||||||
@@ -27,7 +21,6 @@ pub fn create_group(conn: &Connection, id: &str, name: &str) -> Result<(), Strin
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all contact groups with their member counts.
|
|
||||||
pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
|
pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -57,7 +50,6 @@ pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
|
|||||||
Ok(groups)
|
Ok(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a group's name.
|
|
||||||
pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, String> {
|
pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, String> {
|
||||||
let updated = conn
|
let updated = conn
|
||||||
.execute(
|
.execute(
|
||||||
@@ -68,7 +60,6 @@ pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, Str
|
|||||||
Ok(updated > 0)
|
Ok(updated > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a contact group. CASCADE handles member rows.
|
|
||||||
pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
|
pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute("DELETE FROM contact_groups WHERE id = ?1", params![id])
|
.execute("DELETE FROM contact_groups WHERE id = ?1", params![id])
|
||||||
@@ -76,7 +67,6 @@ pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
|
|||||||
Ok(deleted > 0)
|
Ok(deleted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a contact to a group (INSERT OR IGNORE to avoid duplicates).
|
|
||||||
pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<(), String> {
|
pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?1, ?2)",
|
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?1, ?2)",
|
||||||
@@ -86,7 +76,6 @@ pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a contact from a group.
|
|
||||||
pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<bool, String> {
|
pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<bool, String> {
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute(
|
.execute(
|
||||||
@@ -97,7 +86,6 @@ pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Res
|
|||||||
Ok(deleted > 0)
|
Ok(deleted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List members (full Contact objects) for a group.
|
|
||||||
pub fn list_group_members(conn: &Connection, group_id: &str) -> Result<Vec<Contact>, String> {
|
pub fn list_group_members(conn: &Connection, group_id: &str) -> Result<Vec<Contact>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -147,7 +135,6 @@ pub fn list_group_members(conn: &Connection, group_id: &str) -> Result<Vec<Conta
|
|||||||
Ok(contacts)
|
Ok(contacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all groups a given contact belongs to.
|
|
||||||
pub fn list_contact_groups(
|
pub fn list_contact_groups(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
contact_id: &str,
|
contact_id: &str,
|
||||||
@@ -235,7 +222,7 @@ mod tests {
|
|||||||
|
|
||||||
add_member(&conn, "g1", "c1").unwrap();
|
add_member(&conn, "g1", "c1").unwrap();
|
||||||
add_member(&conn, "g1", "c2").unwrap();
|
add_member(&conn, "g1", "c2").unwrap();
|
||||||
// Duplicate add should be ignored
|
|
||||||
add_member(&conn, "g1", "c1").unwrap();
|
add_member(&conn, "g1", "c1").unwrap();
|
||||||
|
|
||||||
let members = list_group_members(&conn, "g1").unwrap();
|
let members = list_group_members(&conn, "g1").unwrap();
|
||||||
|
|||||||
+25
-66
@@ -1,15 +1,13 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// A known address extracted from message headers (not necessarily a contact).
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct KnownAddress {
|
pub struct KnownAddress {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A contact record, mirroring the `contacts` table.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct Contact {
|
pub struct Contact {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -34,7 +32,6 @@ pub struct Contact {
|
|||||||
pub has_photo: bool,
|
pub has_photo: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a rusqlite row to a Contact struct.
|
|
||||||
fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
|
fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
|
||||||
Ok(Contact {
|
Ok(Contact {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
@@ -49,7 +46,8 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
|
|||||||
birthday: row.get(9)?,
|
birthday: row.get(9)?,
|
||||||
emails: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(16)?).unwrap_or_default(),
|
emails: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(16)?).unwrap_or_default(),
|
||||||
phones: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(17)?).unwrap_or_default(),
|
phones: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(17)?).unwrap_or_default(),
|
||||||
addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?).unwrap_or_default(),
|
addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?)
|
||||||
|
.unwrap_or_default(),
|
||||||
is_favorite: row.get::<_, i32>(10)? != 0,
|
is_favorite: row.get::<_, i32>(10)? != 0,
|
||||||
last_contacted: row.get(11)?,
|
last_contacted: row.get(11)?,
|
||||||
contact_count: row.get(12)?,
|
contact_count: row.get(12)?,
|
||||||
@@ -60,22 +58,14 @@ fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The SELECT column list used by all queries that return `Contact`.
|
const CONTACT_SELECT_COLS: &str = "id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
|
||||||
const CONTACT_SELECT_COLS: &str =
|
|
||||||
"id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted, contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = contacts.id) AS has_photo";
|
|
||||||
|
|
||||||
/// Escape `%` and `_` in a search term to prevent LIKE wildcard injection.
|
|
||||||
fn escape_like(term: &str) -> String {
|
fn escape_like(term: &str) -> String {
|
||||||
term.replace('\\', "\\\\")
|
term.replace('\\', "\\\\")
|
||||||
.replace('%', "\\%")
|
.replace('%', "\\%")
|
||||||
.replace('_', "\\_")
|
.replace('_', "\\_")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Insert or replace a contact. All fields are written.
|
|
||||||
pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> {
|
pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO contacts
|
"INSERT INTO contacts
|
||||||
@@ -114,7 +104,6 @@ pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a contact by its primary key `id`. Returns `None` if not found.
|
|
||||||
pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, String> {
|
pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, String> {
|
||||||
let sql = format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1");
|
let sql = format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1");
|
||||||
let result = conn.query_row(&sql, params![id], row_to_contact);
|
let result = conn.query_row(&sql, params![id], row_to_contact);
|
||||||
@@ -126,7 +115,6 @@ pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a contact by email address. Returns `None` if not found.
|
|
||||||
pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Contact>, String> {
|
pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Contact>, String> {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {CONTACT_SELECT_COLS} FROM contacts \
|
"SELECT {CONTACT_SELECT_COLS} FROM contacts \
|
||||||
@@ -142,8 +130,6 @@ pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List contacts with optional search filter on name/email.
|
|
||||||
/// Results are ordered by name ascending, with LIMIT/OFFSET pagination.
|
|
||||||
pub fn list_contacts(
|
pub fn list_contacts(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
@@ -160,8 +146,7 @@ pub fn list_contacts(
|
|||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
LIMIT ?2 OFFSET ?3"
|
LIMIT ?2 OFFSET ?3"
|
||||||
);
|
);
|
||||||
// We need to pass the pattern, so we store it and handle below.
|
|
||||||
// To keep things simple we branch on has_search.
|
|
||||||
drop(pattern);
|
drop(pattern);
|
||||||
(sql, Some(escaped))
|
(sql, Some(escaped))
|
||||||
}
|
}
|
||||||
@@ -206,7 +191,6 @@ pub fn list_contacts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a contact by id. Returns `true` if a row was deleted.
|
|
||||||
pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
|
pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute("DELETE FROM contacts WHERE id = ?1", params![id])
|
.execute("DELETE FROM contacts WHERE id = ?1", params![id])
|
||||||
@@ -214,8 +198,6 @@ pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
|
|||||||
Ok(deleted > 0)
|
Ok(deleted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search contacts by name or email using LIKE with escaped wildcards.
|
|
||||||
/// Results are ordered by name ascending.
|
|
||||||
pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec<Contact>, String> {
|
pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec<Contact>, String> {
|
||||||
let escaped = escape_like(query);
|
let escaped = escape_like(query);
|
||||||
let pattern = format!("%{escaped}%");
|
let pattern = format!("%{escaped}%");
|
||||||
@@ -241,9 +223,6 @@ pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec
|
|||||||
Ok(contacts)
|
Ok(contacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search known addresses from the denormalized `known_addresses` table.
|
|
||||||
/// Excludes any addresses already present in the contacts table.
|
|
||||||
/// Returns distinct addresses ordered by email ascending.
|
|
||||||
pub fn search_known_addresses(
|
pub fn search_known_addresses(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
query: &str,
|
query: &str,
|
||||||
@@ -285,8 +264,6 @@ pub fn search_known_addresses(
|
|||||||
Ok(addresses)
|
Ok(addresses)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Populate the `known_addresses` table from a single message's header fields.
|
|
||||||
/// Uses INSERT OR IGNORE so existing rows are not overwritten.
|
|
||||||
pub fn populate_known_addresses(
|
pub fn populate_known_addresses(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
from_address: &str,
|
from_address: &str,
|
||||||
@@ -294,7 +271,6 @@ pub fn populate_known_addresses(
|
|||||||
to_json: &str,
|
to_json: &str,
|
||||||
cc_json: &str,
|
cc_json: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Insert from address.
|
|
||||||
if !from_address.is_empty() {
|
if !from_address.is_empty() {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
|
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
|
||||||
@@ -304,28 +280,21 @@ pub fn populate_known_addresses(
|
|||||||
.map_err(|e| format!("Failed to insert known address (from): {e}"))?;
|
.map_err(|e| format!("Failed to insert known address (from): {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert to addresses from JSON array.
|
|
||||||
insert_addresses_from_json(conn, to_json)?;
|
insert_addresses_from_json(conn, to_json)?;
|
||||||
|
|
||||||
// Insert cc addresses from JSON array.
|
|
||||||
insert_addresses_from_json(conn, cc_json)?;
|
insert_addresses_from_json(conn, cc_json)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a JSON array of `{"address": "...", "name": "..."}` objects and insert
|
|
||||||
/// each into `known_addresses`.
|
|
||||||
fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), String> {
|
fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), String> {
|
||||||
let entries: Vec<serde_json::Value> = match serde_json::from_str(json) {
|
let entries: Vec<serde_json::Value> = match serde_json::from_str(json) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(_) => return Ok(()), // Invalid JSON, skip silently.
|
Err(_) => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
let email = entry
|
let email = entry.get("address").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.get("address")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
if email.is_empty() {
|
if email.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -341,8 +310,6 @@ fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), Strin
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Increment the contact_count by 1 and set last_contacted to now for the
|
|
||||||
/// contact with the given email address.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> {
|
pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -357,9 +324,6 @@ pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), Str
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auto-add a contact if one with the same email does not already exist.
|
|
||||||
/// Uses INSERT OR IGNORE so existing contacts are not overwritten.
|
|
||||||
/// Source is set to 'auto'.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> {
|
pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> {
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
@@ -372,7 +336,12 @@ pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str, data: &[u8]) -> Result<(), String> {
|
pub fn set_contact_photo(
|
||||||
|
conn: &Connection,
|
||||||
|
contact_id: &str,
|
||||||
|
content_type: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=datetime('now')",
|
"INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=datetime('now')",
|
||||||
params![contact_id, content_type, data],
|
params![contact_id, content_type, data],
|
||||||
@@ -380,7 +349,10 @@ pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result<Option<(String, Vec<u8>)>, String> {
|
pub fn get_contact_photo(
|
||||||
|
conn: &Connection,
|
||||||
|
contact_id: &str,
|
||||||
|
) -> Result<Option<(String, Vec<u8>)>, String> {
|
||||||
match conn.query_row(
|
match conn.query_row(
|
||||||
"SELECT content_type, data FROM contact_photos WHERE contact_id = ?1",
|
"SELECT content_type, data FROM contact_photos WHERE contact_id = ?1",
|
||||||
params![contact_id],
|
params![contact_id],
|
||||||
@@ -393,9 +365,12 @@ pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result<Option<(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result<bool, String> {
|
pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result<bool, String> {
|
||||||
conn.execute("DELETE FROM contact_photos WHERE contact_id = ?1", params![contact_id])
|
conn.execute(
|
||||||
.map(|n| n > 0)
|
"DELETE FROM contact_photos WHERE contact_id = ?1",
|
||||||
.map_err(|e| format!("Failed to delete contact photo: {e}"))
|
params![contact_id],
|
||||||
|
)
|
||||||
|
.map(|n| n > 0)
|
||||||
|
.map_err(|e| format!("Failed to delete contact photo: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -403,7 +378,6 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::pool::open_test_db;
|
use crate::db::pool::open_test_db;
|
||||||
|
|
||||||
/// Helper: create a sample Contact with sensible defaults.
|
|
||||||
fn sample_contact(id: &str, email: &str, name: &str) -> Contact {
|
fn sample_contact(id: &str, email: &str, name: &str) -> Contact {
|
||||||
Contact {
|
Contact {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -457,7 +431,6 @@ mod tests {
|
|||||||
assert_eq!(fetched.id, "c2");
|
assert_eq!(fetched.id, "c2");
|
||||||
assert_eq!(fetched.name, "Bob");
|
assert_eq!(fetched.name, "Bob");
|
||||||
|
|
||||||
// Non-existent email returns None.
|
|
||||||
let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap();
|
let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap();
|
||||||
assert!(missing.is_none());
|
assert!(missing.is_none());
|
||||||
}
|
}
|
||||||
@@ -471,7 +444,6 @@ mod tests {
|
|||||||
let all = list_contacts(&conn, None, 100, 0).unwrap();
|
let all = list_contacts(&conn, None, 100, 0).unwrap();
|
||||||
assert_eq!(all.len(), 2);
|
assert_eq!(all.len(), 2);
|
||||||
|
|
||||||
// Ordered by name ASC: Alice, Bob.
|
|
||||||
assert_eq!(all[0].name, "Alice");
|
assert_eq!(all[0].name, "Alice");
|
||||||
assert_eq!(all[1].name, "Bob");
|
assert_eq!(all[1].name, "Bob");
|
||||||
}
|
}
|
||||||
@@ -486,12 +458,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob Jones")).unwrap();
|
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob Jones")).unwrap();
|
||||||
|
|
||||||
// Search by name should find only Alice.
|
|
||||||
let results = search_contacts(&conn, "Alice", 10).unwrap();
|
let results = search_contacts(&conn, "Alice", 10).unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].name, "Alice Smith");
|
assert_eq!(results[0].name, "Alice Smith");
|
||||||
|
|
||||||
// Search by email should find only Bob.
|
|
||||||
let results = search_contacts(&conn, "bob@", 10).unwrap();
|
let results = search_contacts(&conn, "bob@", 10).unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].name, "Bob Jones");
|
assert_eq!(results[0].name, "Bob Jones");
|
||||||
@@ -505,11 +475,9 @@ mod tests {
|
|||||||
let deleted = delete_contact(&conn, "c1").unwrap();
|
let deleted = delete_contact(&conn, "c1").unwrap();
|
||||||
assert!(deleted);
|
assert!(deleted);
|
||||||
|
|
||||||
// Should be gone now.
|
|
||||||
let fetched = get_contact(&conn, "c1").unwrap();
|
let fetched = get_contact(&conn, "c1").unwrap();
|
||||||
assert!(fetched.is_none());
|
assert!(fetched.is_none());
|
||||||
|
|
||||||
// Deleting again returns false.
|
|
||||||
let deleted_again = delete_contact(&conn, "c1").unwrap();
|
let deleted_again = delete_contact(&conn, "c1").unwrap();
|
||||||
assert!(!deleted_again);
|
assert!(!deleted_again);
|
||||||
}
|
}
|
||||||
@@ -526,12 +494,11 @@ mod tests {
|
|||||||
assert_eq!(fetched.source, "auto");
|
assert_eq!(fetched.source, "auto");
|
||||||
assert_eq!(fetched.name, "Auto User");
|
assert_eq!(fetched.name, "Auto User");
|
||||||
|
|
||||||
// Auto-adding the same email again should be a no-op (INSERT OR IGNORE).
|
|
||||||
auto_add_contact(&conn, "auto@example.com", "Different Name").unwrap();
|
auto_add_contact(&conn, "auto@example.com", "Different Name").unwrap();
|
||||||
let fetched2 = get_contact_by_email(&conn, "auto@example.com")
|
let fetched2 = get_contact_by_email(&conn, "auto@example.com")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(fetched2.name, "Auto User"); // Name should NOT change.
|
assert_eq!(fetched2.name, "Auto User");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -539,7 +506,6 @@ mod tests {
|
|||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
||||||
|
|
||||||
// Increment twice.
|
|
||||||
increment_contact_count(&conn, "alice@example.com").unwrap();
|
increment_contact_count(&conn, "alice@example.com").unwrap();
|
||||||
increment_contact_count(&conn, "alice@example.com").unwrap();
|
increment_contact_count(&conn, "alice@example.com").unwrap();
|
||||||
|
|
||||||
@@ -554,7 +520,6 @@ mod tests {
|
|||||||
fn test_search_known_addresses_basic() {
|
fn test_search_known_addresses_basic() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
// Populate known_addresses directly (simulating what happens at sync time).
|
|
||||||
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
||||||
populate_known_addresses(
|
populate_known_addresses(
|
||||||
&conn,
|
&conn,
|
||||||
@@ -588,14 +553,12 @@ mod tests {
|
|||||||
fn test_search_known_addresses_excludes_contacts() {
|
fn test_search_known_addresses_excludes_contacts() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
// Add alice@test.com as a contact.
|
|
||||||
upsert_contact(
|
upsert_contact(
|
||||||
&conn,
|
&conn,
|
||||||
&sample_contact("c1", "alice@test.com", "Alice Contact"),
|
&sample_contact("c1", "alice@test.com", "Alice Contact"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Populate known_addresses (simulating sync).
|
|
||||||
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
||||||
populate_known_addresses(
|
populate_known_addresses(
|
||||||
&conn,
|
&conn,
|
||||||
@@ -614,10 +577,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Search for "test.com" should exclude alice@test.com (she's a contact).
|
|
||||||
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
||||||
|
|
||||||
// Should only have bob and charlie (alice excluded from all sources).
|
|
||||||
assert_eq!(results.len(), 2);
|
assert_eq!(results.len(), 2);
|
||||||
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
|
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
|
||||||
assert!(emails.contains(&"bob@test.com"));
|
assert!(emails.contains(&"bob@test.com"));
|
||||||
@@ -629,7 +590,6 @@ mod tests {
|
|||||||
fn test_search_known_addresses_handles_invalid_json() {
|
fn test_search_known_addresses_handles_invalid_json() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
// Populate known_addresses (simulating sync).
|
|
||||||
populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap();
|
populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap();
|
||||||
populate_known_addresses(
|
populate_known_addresses(
|
||||||
&conn,
|
&conn,
|
||||||
@@ -663,7 +623,7 @@ mod tests {
|
|||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
populate_known_addresses(&conn, "alice@test.com", "Alice", "[]", "[]").unwrap();
|
populate_known_addresses(&conn, "alice@test.com", "Alice", "[]", "[]").unwrap();
|
||||||
// Same email again with non-empty name -- should update the name.
|
|
||||||
populate_known_addresses(&conn, "alice@test.com", "Alice Different", "[]", "[]").unwrap();
|
populate_known_addresses(&conn, "alice@test.com", "Alice Different", "[]", "[]").unwrap();
|
||||||
|
|
||||||
let results = search_known_addresses(&conn, "alice@test.com", 10).unwrap();
|
let results = search_known_addresses(&conn, "alice@test.com", 10).unwrap();
|
||||||
@@ -675,7 +635,6 @@ mod tests {
|
|||||||
fn test_populate_known_addresses_skips_invalid_json() {
|
fn test_populate_known_addresses_skips_invalid_json() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
// Invalid JSON in to_addresses should not cause an error.
|
|
||||||
populate_known_addresses(&conn, "sender@test.com", "Sender", "not valid json", "[]")
|
populate_known_addresses(&conn, "sender@test.com", "Sender", "not valid json", "[]")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
+110
-26
@@ -1,7 +1,6 @@
|
|||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, params};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
/// A locally-saved draft message.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct Draft {
|
pub struct Draft {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -27,7 +26,6 @@ pub struct Draft {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An attachment associated with a draft.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct DraftAttachment {
|
pub struct DraftAttachment {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -39,8 +37,6 @@ pub struct DraftAttachment {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Validate draft IDs before they are used as database keys or path segments.
|
|
||||||
pub fn validate_draft_id(id: &str) -> Result<(), String> {
|
pub fn validate_draft_id(id: &str) -> Result<(), String> {
|
||||||
if id.is_empty() || id.len() > 128 {
|
if id.is_empty() || id.len() > 128 {
|
||||||
return Err("Draft ID must be between 1 and 128 characters".to_string());
|
return Err("Draft ID must be between 1 and 128 characters".to_string());
|
||||||
@@ -54,7 +50,6 @@ pub fn validate_draft_id(id: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert or update a draft. Uses INSERT OR REPLACE to upsert.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn upsert_draft(
|
pub fn upsert_draft(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -119,7 +114,6 @@ pub fn upsert_draft(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve a single draft by ID.
|
|
||||||
pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
|
pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
|
||||||
let result = conn.query_row(
|
let result = conn.query_row(
|
||||||
"SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
|
"SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
|
||||||
@@ -159,7 +153,6 @@ pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all drafts, ordered by updated_at descending.
|
|
||||||
pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
|
pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -203,8 +196,6 @@ pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
|
|||||||
Ok(drafts)
|
Ok(drafts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Update the IMAP location of the latest remote snapshot for a local draft.
|
|
||||||
pub fn set_remote_location(
|
pub fn set_remote_location(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -224,7 +215,6 @@ pub fn set_remote_location(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a draft by ID. Cascade will handle attachments in DB.
|
|
||||||
pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
|
pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute("DELETE FROM drafts WHERE id = ?1", params![id])
|
.execute("DELETE FROM drafts WHERE id = ?1", params![id])
|
||||||
@@ -232,7 +222,6 @@ pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
|
|||||||
Ok(deleted > 0)
|
Ok(deleted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an attachment record for a draft.
|
|
||||||
pub fn add_draft_attachment(
|
pub fn add_draft_attachment(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -251,7 +240,6 @@ pub fn add_draft_attachment(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all attachments for a draft.
|
|
||||||
pub fn get_draft_attachments(
|
pub fn get_draft_attachments(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
draft_id: &str,
|
draft_id: &str,
|
||||||
@@ -284,7 +272,6 @@ pub fn get_draft_attachments(
|
|||||||
Ok(attachments)
|
Ok(attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a single attachment by ID.
|
|
||||||
pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result<bool, String> {
|
pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute("DELETE FROM draft_attachments WHERE id = ?1", params![id])
|
.execute("DELETE FROM draft_attachments WHERE id = ?1", params![id])
|
||||||
@@ -336,8 +323,46 @@ mod tests {
|
|||||||
fn test_upsert_updates_existing() {
|
fn test_upsert_updates_existing() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
upsert_draft(&conn, "draft-1", "bob@example.com", "", "", "v1", "body1", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
upsert_draft(
|
||||||
upsert_draft(&conn, "draft-1", "carol@example.com", "", "", "v2", "body2", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
&conn,
|
||||||
|
"draft-1",
|
||||||
|
"bob@example.com",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"v1",
|
||||||
|
"body1",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
"normal",
|
||||||
|
"html",
|
||||||
|
"none",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
upsert_draft(
|
||||||
|
&conn,
|
||||||
|
"draft-1",
|
||||||
|
"carol@example.com",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"v2",
|
||||||
|
"body2",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
"normal",
|
||||||
|
"html",
|
||||||
|
"none",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
||||||
assert_eq!(draft.to_addresses, "carol@example.com");
|
assert_eq!(draft.to_addresses, "carol@example.com");
|
||||||
@@ -349,8 +374,16 @@ mod tests {
|
|||||||
fn test_list_drafts_returns_all() {
|
fn test_list_drafts_returns_all() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
upsert_draft(&conn, "draft-a", "", "", "", "First", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
upsert_draft(
|
||||||
upsert_draft(&conn, "draft-b", "", "", "", "Second", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
&conn, "draft-a", "", "", "", "First", "", None, None, None, false, "normal", "html",
|
||||||
|
"none", false, false, false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
upsert_draft(
|
||||||
|
&conn, "draft-b", "", "", "", "Second", "", None, None, None, false, "normal", "html",
|
||||||
|
"none", false, false, false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let drafts = list_drafts(&conn).unwrap();
|
let drafts = list_drafts(&conn).unwrap();
|
||||||
assert_eq!(drafts.len(), 2);
|
assert_eq!(drafts.len(), 2);
|
||||||
@@ -363,7 +396,11 @@ mod tests {
|
|||||||
fn test_delete_draft() {
|
fn test_delete_draft() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
upsert_draft(
|
||||||
|
&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html",
|
||||||
|
"none", false, false, false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(get_draft(&conn, "draft-1").unwrap().is_some());
|
assert!(get_draft(&conn, "draft-1").unwrap().is_some());
|
||||||
|
|
||||||
let deleted = delete_draft(&conn, "draft-1").unwrap();
|
let deleted = delete_draft(&conn, "draft-1").unwrap();
|
||||||
@@ -374,9 +411,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn remote_location_survives_local_draft_update() {
|
fn remote_location_survives_local_draft_update() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
upsert_draft(&conn, "draft-1", "", "", "", "v1", "body", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
upsert_draft(
|
||||||
|
&conn, "draft-1", "", "", "", "v1", "body", None, None, None, false, "normal", "html",
|
||||||
|
"none", false, false, false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
set_remote_location(&conn, "draft-1", "Drafts", 42, 7).unwrap();
|
set_remote_location(&conn, "draft-1", "Drafts", 42, 7).unwrap();
|
||||||
upsert_draft(&conn, "draft-1", "", "", "", "v2", "body2", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
upsert_draft(
|
||||||
|
&conn, "draft-1", "", "", "", "v2", "body2", None, None, None, false, "normal", "html",
|
||||||
|
"none", false, false, false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
||||||
assert_eq!(draft.remote_folder.as_deref(), Some("Drafts"));
|
assert_eq!(draft.remote_folder.as_deref(), Some("Drafts"));
|
||||||
assert_eq!(draft.remote_uid, Some(42));
|
assert_eq!(draft.remote_uid, Some(42));
|
||||||
@@ -408,7 +453,11 @@ mod tests {
|
|||||||
fn test_add_and_get_attachments() {
|
fn test_add_and_get_attachments() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
upsert_draft(
|
||||||
|
&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html",
|
||||||
|
"none", false, false, false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
add_draft_attachment(
|
add_draft_attachment(
|
||||||
&conn,
|
&conn,
|
||||||
@@ -443,8 +492,21 @@ mod tests {
|
|||||||
fn test_delete_attachment() {
|
fn test_delete_attachment() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
upsert_draft(
|
||||||
add_draft_attachment(&conn, "att-1", "draft-1", "file.pdf", "application/pdf", 100, "/path").unwrap();
|
&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html",
|
||||||
|
"none", false, false, false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
add_draft_attachment(
|
||||||
|
&conn,
|
||||||
|
"att-1",
|
||||||
|
"draft-1",
|
||||||
|
"file.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
100,
|
||||||
|
"/path",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let deleted = delete_draft_attachment(&conn, "att-1").unwrap();
|
let deleted = delete_draft_attachment(&conn, "att-1").unwrap();
|
||||||
assert!(deleted);
|
assert!(deleted);
|
||||||
@@ -457,9 +519,31 @@ mod tests {
|
|||||||
fn test_cascade_delete_attachments_on_draft_delete() {
|
fn test_cascade_delete_attachments_on_draft_delete() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
upsert_draft(
|
||||||
add_draft_attachment(&conn, "att-1", "draft-1", "file.pdf", "application/pdf", 100, "/path").unwrap();
|
&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html",
|
||||||
add_draft_attachment(&conn, "att-2", "draft-1", "pic.jpg", "image/jpeg", 200, "/path2").unwrap();
|
"none", false, false, false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
add_draft_attachment(
|
||||||
|
&conn,
|
||||||
|
"att-1",
|
||||||
|
"draft-1",
|
||||||
|
"file.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
100,
|
||||||
|
"/path",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
add_draft_attachment(
|
||||||
|
&conn,
|
||||||
|
"att-2",
|
||||||
|
"draft-1",
|
||||||
|
"pic.jpg",
|
||||||
|
"image/jpeg",
|
||||||
|
200,
|
||||||
|
"/path2",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
delete_draft(&conn, "draft-1").unwrap();
|
delete_draft(&conn, "draft-1").unwrap();
|
||||||
|
|
||||||
|
|||||||
+117
-82
@@ -1,7 +1,6 @@
|
|||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
/// A cached IMAP folder, mirroring the `folders` table schema.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct CachedFolder {
|
pub struct CachedFolder {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -15,7 +14,6 @@ pub struct CachedFolder {
|
|||||||
pub highest_modseq: u64,
|
pub highest_modseq: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert or replace a folder row in the `folders` table.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn upsert_folder(
|
pub fn upsert_folder(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -50,11 +48,6 @@ pub fn upsert_folder(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a folder only if it doesn't already exist.
|
|
||||||
///
|
|
||||||
/// Uses `INSERT OR IGNORE` to avoid triggering `ON DELETE CASCADE` on the
|
|
||||||
/// messages table (which `INSERT OR REPLACE` would do since it is internally
|
|
||||||
/// a DELETE + INSERT).
|
|
||||||
pub fn insert_folder_if_new(
|
pub fn insert_folder_if_new(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder_name: &str,
|
folder_name: &str,
|
||||||
@@ -71,7 +64,6 @@ pub fn insert_folder_if_new(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all cached folders, sorted alphabetically by name.
|
|
||||||
pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
|
pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -107,24 +99,15 @@ pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
|
|||||||
Ok(folders)
|
Ok(folders)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete folders whose names are NOT in the provided `current_names` list.
|
pub fn remove_stale_folders(conn: &Connection, current_names: &[String]) -> Result<usize, String> {
|
||||||
/// Returns the number of deleted rows.
|
|
||||||
pub fn remove_stale_folders(
|
|
||||||
conn: &Connection,
|
|
||||||
current_names: &[String],
|
|
||||||
) -> Result<usize, String> {
|
|
||||||
if current_names.is_empty() {
|
if current_names.is_empty() {
|
||||||
// Delete all folders when the current list is empty.
|
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute("DELETE FROM folders", [])
|
.execute("DELETE FROM folders", [])
|
||||||
.map_err(|e| format!("Failed to delete all folders: {e}"))?;
|
.map_err(|e| format!("Failed to delete all folders: {e}"))?;
|
||||||
return Ok(deleted);
|
return Ok(deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a parameterized WHERE NOT IN clause.
|
let placeholders: Vec<String> = (1..=current_names.len()).map(|i| format!("?{i}")).collect();
|
||||||
let placeholders: Vec<String> = (1..=current_names.len())
|
|
||||||
.map(|i| format!("?{i}"))
|
|
||||||
.collect();
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"DELETE FROM folders WHERE name NOT IN ({})",
|
"DELETE FROM folders WHERE name NOT IN ({})",
|
||||||
placeholders.join(", ")
|
placeholders.join(", ")
|
||||||
@@ -142,18 +125,12 @@ pub fn remove_stale_folders(
|
|||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Touch `updated_at` on all folders so the folder-list cache TTL resets.
|
|
||||||
pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
|
pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute("UPDATE folders SET updated_at = datetime('now')", [])
|
||||||
"UPDATE folders SET updated_at = datetime('now')",
|
.map_err(|e| format!("Failed to touch folders: {e}"))?;
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to touch folders: {e}"))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update only the uid_validity and total_count for a folder (without CASCADE side-effects).
|
|
||||||
/// Also touches `messages_updated_at` to mark when this folder's messages were last synced.
|
|
||||||
pub fn update_folder_status(
|
pub fn update_folder_status(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -169,9 +146,6 @@ pub fn update_folder_status(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update server-side folder metadata after a CONDSTORE or STATUS check.
|
|
||||||
/// Does not touch `messages_updated_at`: metadata alone does not mean the
|
|
||||||
/// local message cache has been synchronized.
|
|
||||||
pub fn update_folder_sync_status(
|
pub fn update_folder_sync_status(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -188,7 +162,6 @@ pub fn update_folder_sync_status(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recompute and store the unread count for a folder from the messages table.
|
|
||||||
pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||||
let unread: u32 = conn
|
let unread: u32 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -207,8 +180,6 @@ pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(),
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adjust a folder's unread count by a signed delta (positive to increase, negative to decrease).
|
|
||||||
/// Clamps to zero to avoid negative counts.
|
|
||||||
pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> {
|
pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2",
|
"UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2",
|
||||||
@@ -218,8 +189,6 @@ pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether any folder was updated within the last `max_age_secs` seconds.
|
|
||||||
/// Returns `true` if the cache is still fresh, `false` if stale or empty.
|
|
||||||
pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result<bool, String> {
|
pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result<bool, String> {
|
||||||
let fresh: bool = conn
|
let fresh: bool = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -234,10 +203,11 @@ pub fn is_folder_cache_fresh(conn: &Connection, max_age_secs: u32) -> Result<boo
|
|||||||
Ok(fresh)
|
Ok(fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether a specific folder's messages were synced within the last `max_age_secs` seconds.
|
pub fn is_folder_fresh(
|
||||||
/// Uses `messages_updated_at` which is set by `update_folder_status` (message sync),
|
conn: &Connection,
|
||||||
/// independent of `updated_at` which tracks folder-list sync.
|
folder_name: &str,
|
||||||
pub fn is_folder_fresh(conn: &Connection, folder_name: &str, max_age_secs: u32) -> Result<bool, String> {
|
max_age_secs: u32,
|
||||||
|
) -> Result<bool, String> {
|
||||||
let fresh: bool = conn
|
let fresh: bool = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT EXISTS(
|
"SELECT EXISTS(
|
||||||
@@ -251,10 +221,10 @@ pub fn is_folder_fresh(conn: &Connection, folder_name: &str, max_age_secs: u32)
|
|||||||
Ok(fresh)
|
Ok(fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether a folder's messages cache has been invalidated
|
pub fn is_folder_messages_invalidated(
|
||||||
/// (i.e., `messages_updated_at IS NULL`), meaning its unread count was
|
conn: &Connection,
|
||||||
/// manually adjusted and should not be recomputed from the messages table.
|
folder_name: &str,
|
||||||
pub fn is_folder_messages_invalidated(conn: &Connection, folder_name: &str) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let invalidated: bool = conn
|
let invalidated: bool = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1",
|
"SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1",
|
||||||
@@ -265,8 +235,6 @@ pub fn is_folder_messages_invalidated(conn: &Connection, folder_name: &str) -> R
|
|||||||
Ok(invalidated)
|
Ok(invalidated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear a folder's `messages_updated_at` so the next `is_folder_fresh` check
|
|
||||||
/// returns `false` and forces an IMAP resync.
|
|
||||||
pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE folders SET messages_updated_at = NULL WHERE name = ?1",
|
"UPDATE folders SET messages_updated_at = NULL WHERE name = ?1",
|
||||||
@@ -276,7 +244,6 @@ pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Resu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a single folder by name, or `None` if not found.
|
|
||||||
pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>, String> {
|
pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>, String> {
|
||||||
let result = conn.query_row(
|
let result = conn.query_row(
|
||||||
"SELECT name, delimiter, parent, flags, is_subscribed,
|
"SELECT name, delimiter, parent, flags, is_subscribed,
|
||||||
@@ -308,18 +275,25 @@ pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a folder and all its cached messages.
|
|
||||||
pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||||
conn.execute("DELETE FROM messages WHERE folder = ?1", params![folder_name])
|
conn.execute(
|
||||||
.map_err(|e| format!("Failed to delete folder messages: {e}"))?;
|
"DELETE FROM messages WHERE folder = ?1",
|
||||||
|
params![folder_name],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to delete folder messages: {e}"))?;
|
||||||
conn.execute("DELETE FROM folders WHERE name = ?1", params![folder_name])
|
conn.execute("DELETE FROM folders WHERE name = ?1", params![folder_name])
|
||||||
.map_err(|e| format!("Failed to delete folder: {e}"))?;
|
.map_err(|e| format!("Failed to delete folder: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rename a folder in the cache, updating both folders and messages tables.
|
pub fn rename_folder_in_cache(
|
||||||
pub fn rename_folder_in_cache(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> {
|
conn: &Connection,
|
||||||
let tx = conn.unchecked_transaction().map_err(|e| format!("Failed to start folder rename transaction: {e}"))?;
|
old_name: &str,
|
||||||
|
new_name: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let tx = conn
|
||||||
|
.unchecked_transaction()
|
||||||
|
.map_err(|e| format!("Failed to start folder rename transaction: {e}"))?;
|
||||||
tx.execute_batch("PRAGMA defer_foreign_keys = ON;")
|
tx.execute_batch("PRAGMA defer_foreign_keys = ON;")
|
||||||
.map_err(|e| format!("Failed to defer folder rename foreign keys: {e}"))?;
|
.map_err(|e| format!("Failed to defer folder rename foreign keys: {e}"))?;
|
||||||
|
|
||||||
@@ -333,23 +307,37 @@ pub fn rename_folder_in_cache(conn: &Connection, old_name: &str, new_name: &str)
|
|||||||
return Err(format!("Folder not found in cache: {old_name}"));
|
return Err(format!("Folder not found in cache: {old_name}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.execute("UPDATE messages SET folder = ?1 WHERE folder = ?2", params![new_name, old_name])
|
tx.execute(
|
||||||
.map_err(|e| format!("Failed to rename messages folder: {e}"))?;
|
"UPDATE messages SET folder = ?1 WHERE folder = ?2",
|
||||||
tx.execute("UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2", params![new_name, old_name])
|
params![new_name, old_name],
|
||||||
.map_err(|e| format!("Failed to rename message tag folder references: {e}"))?;
|
)
|
||||||
tx.execute("UPDATE calendar_events SET source_message_folder = ?1 WHERE source_message_folder = ?2", params![new_name, old_name])
|
.map_err(|e| format!("Failed to rename messages folder: {e}"))?;
|
||||||
.ok();
|
tx.execute(
|
||||||
tx.execute("UPDATE folders SET parent = ?1 WHERE parent = ?2", params![new_name, old_name])
|
"UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2",
|
||||||
.map_err(|e| format!("Failed to update child folder parent: {e}"))?;
|
params![new_name, old_name],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to rename message tag folder references: {e}"))?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE calendar_events SET source_message_folder = ?1 WHERE source_message_folder = ?2",
|
||||||
|
params![new_name, old_name],
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE folders SET parent = ?1 WHERE parent = ?2",
|
||||||
|
params![new_name, old_name],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to update child folder parent: {e}"))?;
|
||||||
tx.execute("DELETE FROM folders WHERE name = ?1", params![old_name])
|
tx.execute("DELETE FROM folders WHERE name = ?1", params![old_name])
|
||||||
.map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?;
|
.map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?;
|
||||||
tx.commit().map_err(|e| format!("Failed to commit folder rename: {e}"))?;
|
tx.commit()
|
||||||
|
.map_err(|e| format!("Failed to commit folder rename: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a folder whose `flags` column contains the given attribute (e.g. `\Drafts`, `\Sent`).
|
pub fn find_folder_by_attribute(
|
||||||
/// Returns the folder name if found.
|
conn: &Connection,
|
||||||
pub fn find_folder_by_attribute(conn: &Connection, attribute: &str) -> Result<Option<String>, String> {
|
attribute: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1",
|
"SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1",
|
||||||
[attribute],
|
[attribute],
|
||||||
@@ -368,15 +356,36 @@ mod tests {
|
|||||||
fn test_upsert_and_get_folders() {
|
fn test_upsert_and_get_folders() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 42, 5, 100, 200)
|
upsert_folder(
|
||||||
.unwrap();
|
&conn,
|
||||||
upsert_folder(&conn, "Sent", Some("/"), None, "\\Sent", true, 10, 0, 101, 300)
|
"INBOX",
|
||||||
.unwrap();
|
Some("/"),
|
||||||
|
None,
|
||||||
|
"\\HasNoChildren",
|
||||||
|
true,
|
||||||
|
42,
|
||||||
|
5,
|
||||||
|
100,
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
upsert_folder(
|
||||||
|
&conn,
|
||||||
|
"Sent",
|
||||||
|
Some("/"),
|
||||||
|
None,
|
||||||
|
"\\Sent",
|
||||||
|
true,
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
101,
|
||||||
|
300,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let folders = get_all_folders(&conn).unwrap();
|
let folders = get_all_folders(&conn).unwrap();
|
||||||
assert_eq!(folders.len(), 2);
|
assert_eq!(folders.len(), 2);
|
||||||
|
|
||||||
// Sorted alphabetically: INBOX < Sent
|
|
||||||
assert_eq!(folders[0].name, "INBOX");
|
assert_eq!(folders[0].name, "INBOX");
|
||||||
assert_eq!(folders[0].total_count, 42);
|
assert_eq!(folders[0].total_count, 42);
|
||||||
assert_eq!(folders[0].unread_count, 5);
|
assert_eq!(folders[0].unread_count, 5);
|
||||||
@@ -390,12 +399,33 @@ mod tests {
|
|||||||
fn test_upsert_updates_existing_folder() {
|
fn test_upsert_updates_existing_folder() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 10, 2, 100, 50)
|
upsert_folder(
|
||||||
.unwrap();
|
&conn,
|
||||||
|
"INBOX",
|
||||||
|
Some("/"),
|
||||||
|
None,
|
||||||
|
"\\HasNoChildren",
|
||||||
|
true,
|
||||||
|
10,
|
||||||
|
2,
|
||||||
|
100,
|
||||||
|
50,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Upsert again with different counts.
|
upsert_folder(
|
||||||
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 99, 33, 100, 75)
|
&conn,
|
||||||
.unwrap();
|
"INBOX",
|
||||||
|
Some("/"),
|
||||||
|
None,
|
||||||
|
"\\HasNoChildren",
|
||||||
|
true,
|
||||||
|
99,
|
||||||
|
33,
|
||||||
|
100,
|
||||||
|
75,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let folders = get_all_folders(&conn).unwrap();
|
let folders = get_all_folders(&conn).unwrap();
|
||||||
assert_eq!(folders.len(), 1);
|
assert_eq!(folders.len(), 1);
|
||||||
@@ -416,7 +446,10 @@ mod tests {
|
|||||||
|
|
||||||
assert!(is_folder_messages_invalidated(&conn, "Sent").unwrap());
|
assert!(is_folder_messages_invalidated(&conn, "Sent").unwrap());
|
||||||
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
|
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
|
||||||
assert_eq!(crate::db::messages::count_messages(&conn, "Sent").unwrap(), 0);
|
assert_eq!(
|
||||||
|
crate::db::messages::count_messages(&conn, "Sent").unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
let folder = get_folder(&conn, "Sent").unwrap().unwrap();
|
let folder = get_folder(&conn, "Sent").unwrap().unwrap();
|
||||||
assert_eq!(folder.uid_validity, 2);
|
assert_eq!(folder.uid_validity, 2);
|
||||||
assert_eq!(folder.total_count, 1);
|
assert_eq!(folder.total_count, 1);
|
||||||
@@ -430,15 +463,18 @@ mod tests {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'",
|
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
update_folder_sync_status(&conn, "Sent", 1, 5, 42).unwrap();
|
update_folder_sync_status(&conn, "Sent", 1, 5, 42).unwrap();
|
||||||
|
|
||||||
let timestamp: String = conn.query_row(
|
let timestamp: String = conn
|
||||||
"SELECT messages_updated_at FROM folders WHERE name = 'Sent'",
|
.query_row(
|
||||||
[],
|
"SELECT messages_updated_at FROM folders WHERE name = 'Sent'",
|
||||||
|row| row.get(0),
|
[],
|
||||||
).unwrap();
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(timestamp, "2000-01-01 00:00:00");
|
assert_eq!(timestamp, "2000-01-01 00:00:00");
|
||||||
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
|
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
|
||||||
}
|
}
|
||||||
@@ -451,7 +487,6 @@ mod tests {
|
|||||||
upsert_folder(&conn, "Sent", None, None, "", true, 0, 0, 0, 0).unwrap();
|
upsert_folder(&conn, "Sent", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||||
upsert_folder(&conn, "Trash", None, None, "", true, 0, 0, 0, 0).unwrap();
|
upsert_folder(&conn, "Trash", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||||
|
|
||||||
// Keep only INBOX and Sent; Trash should be removed.
|
|
||||||
let current = vec!["INBOX".to_string(), "Sent".to_string()];
|
let current = vec!["INBOX".to_string(), "Sent".to_string()];
|
||||||
let deleted = remove_stale_folders(&conn, ¤t).unwrap();
|
let deleted = remove_stale_folders(&conn, ¤t).unwrap();
|
||||||
assert_eq!(deleted, 1);
|
assert_eq!(deleted, 1);
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//! Reject header batches whose IMAP reads overlap a completed cache removal.
|
|
||||||
//!
|
|
||||||
//! The generation is per user database, not per UID: a later sync may discover
|
|
||||||
//! new destination UIDs or reuse UIDs after UIDVALIDITY changes. No tombstones
|
|
||||||
//! or locks held over network calls are needed.
|
|
||||||
|
|
||||||
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior};
|
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -12,7 +6,6 @@ pub(crate) struct HeaderSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HeaderSync {
|
impl HeaderSync {
|
||||||
/// Capture before reading IMAP state, and retain for the whole sync pass.
|
|
||||||
pub(crate) fn capture(conn: &Connection) -> Result<Self, String> {
|
pub(crate) fn capture(conn: &Connection) -> Result<Self, String> {
|
||||||
let generation = conn
|
let generation = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -28,7 +21,6 @@ impl HeaderSync {
|
|||||||
Ok(Self { generation })
|
Ok(Self { generation })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize the check and all header/index/status writes with removals.
|
|
||||||
pub(crate) fn begin_write(self, conn: &Connection) -> Result<Transaction<'_>, String> {
|
pub(crate) fn begin_write(self, conn: &Connection) -> Result<Transaction<'_>, String> {
|
||||||
let transaction = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
|
let transaction = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -39,7 +31,6 @@ impl HeaderSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Must run in the same write transaction as the removal or UIDVALIDITY reset.
|
|
||||||
pub(crate) fn invalidate(conn: &Transaction<'_>) -> Result<(), String> {
|
pub(crate) fn invalidate(conn: &Transaction<'_>) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
|
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
|
||||||
|
|||||||
+311
-170
@@ -1,9 +1,6 @@
|
|||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, params};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
// mail_parser is used for RFC2822 date parsing in parse_date_to_epoch.
|
|
||||||
|
|
||||||
/// Message data needed to rebuild the full-text search index from SQLite.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct IndexSourceMessage {
|
pub struct IndexSourceMessage {
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
@@ -17,8 +14,6 @@ pub struct IndexSourceMessage {
|
|||||||
pub has_attachments: bool,
|
pub has_attachments: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cached email message header, mirroring the query-visible columns of the
|
|
||||||
/// `messages` table.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct CachedMessage {
|
pub struct CachedMessage {
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
@@ -39,10 +34,6 @@ pub struct CachedMessage {
|
|||||||
pub reaction: Option<String>,
|
pub reaction: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper to map a row to CachedMessage (used in multiple queries)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMessage> {
|
fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMessage> {
|
||||||
let has_attachments_int: i32 = row.get(12)?;
|
let has_attachments_int: i32 = row.get(12)?;
|
||||||
Ok(CachedMessage {
|
Ok(CachedMessage {
|
||||||
@@ -65,17 +56,10 @@ fn row_to_cached_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedMess
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The SELECT column list used by all queries that return `CachedMessage`.
|
const MSG_SELECT_COLS: &str = "uid, folder, message_id, in_reply_to, references_header,
|
||||||
const MSG_SELECT_COLS: &str =
|
|
||||||
"uid, folder, message_id, in_reply_to, references_header,
|
|
||||||
subject, from_address, from_name, to_addresses, cc_addresses,
|
subject, from_address, from_name, to_addresses, cc_addresses,
|
||||||
date, flags, has_attachments, size, snippet, reaction";
|
date, flags, has_attachments, size, snippet, reaction";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Insert or replace a message header row.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn upsert_message(
|
pub fn upsert_message(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -127,8 +111,6 @@ pub fn upsert_message(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a successfully moved/deleted source UID and reject older header reads.
|
|
||||||
/// Invalidate freshness so a rejected batch is retried by the next list request.
|
|
||||||
pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
|
pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
|
||||||
let transaction =
|
let transaction =
|
||||||
rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
|
rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
|
||||||
@@ -139,7 +121,6 @@ pub fn remove_after_mutation(conn: &Connection, folder: &str, uid: u32) -> Resul
|
|||||||
transaction.commit().map_err(|e| e.to_string())
|
transaction.commit().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete all messages in a folder (used when UIDVALIDITY changes).
|
|
||||||
pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize, String> {
|
pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize, String> {
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute("DELETE FROM messages WHERE folder = ?1", params![folder])
|
.execute("DELETE FROM messages WHERE folder = ?1", params![folder])
|
||||||
@@ -147,7 +128,6 @@ pub fn delete_folder_messages(conn: &Connection, folder: &str) -> Result<usize,
|
|||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the highest cached UID for a folder, or 0 if no messages are cached.
|
|
||||||
pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
|
pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1",
|
"SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder = ?1",
|
||||||
@@ -157,9 +137,6 @@ pub fn max_uid(conn: &Connection, folder: &str) -> Result<u32, String> {
|
|||||||
.map_err(|e| format!("Failed to get max uid: {e}"))
|
.map_err(|e| format!("Failed to get max uid: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all cached message data needed for a full Tantivy rebuild.
|
|
||||||
/// Body text falls back to the message snippet when the full body has not yet
|
|
||||||
/// been cached, so a rebuild remains useful before every message is opened.
|
|
||||||
pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMessage>, String> {
|
pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMessage>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -193,27 +170,22 @@ pub fn list_index_source_messages(conn: &Connection) -> Result<Vec<IndexSourceMe
|
|||||||
Ok(messages)
|
Ok(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public wrapper to parse a date string to a Unix epoch timestamp.
|
|
||||||
/// Useful when other modules need to compute `date_epoch` from a raw date string.
|
|
||||||
pub fn parse_date_to_epoch_public(date: &str) -> i64 {
|
pub fn parse_date_to_epoch_public(date: &str) -> i64 {
|
||||||
parse_date_to_epoch(date)
|
parse_date_to_epoch(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a date string to a Unix epoch timestamp (seconds).
|
|
||||||
/// Tries RFC2822 first (IMAP dates), then ISO 8601.
|
|
||||||
/// Returns 0 on parse failure.
|
|
||||||
fn parse_date_to_epoch(date: &str) -> i64 {
|
fn parse_date_to_epoch(date: &str) -> i64 {
|
||||||
if date.is_empty() {
|
if date.is_empty() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
// Try ISO 8601 format first (used in test fixtures and some IMAP servers).
|
|
||||||
if let Ok(epoch) = parse_iso8601_to_epoch(date) {
|
if let Ok(epoch) = parse_iso8601_to_epoch(date) {
|
||||||
return epoch;
|
return epoch;
|
||||||
}
|
}
|
||||||
// Try mail-parser's RFC2822 date parsing (standard IMAP date format).
|
|
||||||
if let Some(dt) = mail_parser::DateTime::parse_rfc822(date) {
|
if let Some(dt) = mail_parser::DateTime::parse_rfc822(date) {
|
||||||
let epoch = datetime_to_epoch(&dt);
|
let epoch = datetime_to_epoch(&dt);
|
||||||
// Sanity check: mail_parser can misparse non-RFC2822 dates.
|
|
||||||
if dt.year >= 1970 && dt.month >= 1 && dt.month <= 12 && epoch > 0 {
|
if dt.year >= 1970 && dt.month >= 1 && dt.month <= 12 && epoch > 0 {
|
||||||
return epoch;
|
return epoch;
|
||||||
}
|
}
|
||||||
@@ -221,9 +193,7 @@ fn parse_date_to_epoch(date: &str) -> i64 {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a mail_parser::DateTime to a Unix epoch timestamp.
|
|
||||||
fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
|
fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
|
||||||
// Days from year 1970 to the start of the given year
|
|
||||||
let year = dt.year as i64;
|
let year = dt.year as i64;
|
||||||
let mut days: i64 = 0;
|
let mut days: i64 = 0;
|
||||||
for y in 1970..year {
|
for y in 1970..year {
|
||||||
@@ -232,7 +202,7 @@ fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
|
|||||||
for y in (year..1970).rev() {
|
for y in (year..1970).rev() {
|
||||||
days -= if is_leap_year(y) { 366 } else { 365 };
|
days -= if is_leap_year(y) { 366 } else { 365 };
|
||||||
}
|
}
|
||||||
// Days from start of year to start of month
|
|
||||||
let month_days: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
let month_days: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
let month = (dt.month as usize).saturating_sub(1).min(11);
|
let month = (dt.month as usize).saturating_sub(1).min(11);
|
||||||
for (m, &d) in month_days.iter().enumerate().take(month) {
|
for (m, &d) in month_days.iter().enumerate().take(month) {
|
||||||
@@ -243,8 +213,9 @@ fn datetime_to_epoch(dt: &mail_parser::DateTime) -> i64 {
|
|||||||
}
|
}
|
||||||
days += (dt.day as i64) - 1;
|
days += (dt.day as i64) - 1;
|
||||||
|
|
||||||
let secs = days * 86400 + (dt.hour as i64) * 3600 + (dt.minute as i64) * 60 + (dt.second as i64);
|
let secs =
|
||||||
// tz_before_gmt=true means the offset is negative (behind UTC)
|
days * 86400 + (dt.hour as i64) * 3600 + (dt.minute as i64) * 60 + (dt.second as i64);
|
||||||
|
|
||||||
let tz_offset_secs = (dt.tz_hour as i64) * 3600 + (dt.tz_minute as i64) * 60;
|
let tz_offset_secs = (dt.tz_hour as i64) * 3600 + (dt.tz_minute as i64) * 60;
|
||||||
if dt.tz_before_gmt {
|
if dt.tz_before_gmt {
|
||||||
secs + tz_offset_secs
|
secs + tz_offset_secs
|
||||||
@@ -257,9 +228,7 @@ fn is_leap_year(y: i64) -> bool {
|
|||||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse ISO 8601 dates like "2024-01-01T10:00:00Z"
|
|
||||||
fn parse_iso8601_to_epoch(s: &str) -> Result<i64, ()> {
|
fn parse_iso8601_to_epoch(s: &str) -> Result<i64, ()> {
|
||||||
// Simple parser for YYYY-MM-DDTHH:MM:SSZ
|
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
if s.len() < 19 {
|
if s.len() < 19 {
|
||||||
return Err(());
|
return Err(());
|
||||||
@@ -285,8 +254,6 @@ fn parse_iso8601_to_epoch(s: &str) -> Result<i64, ()> {
|
|||||||
Ok(datetime_to_epoch(&dt))
|
Ok(datetime_to_epoch(&dt))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a page of messages for a folder, ordered by date descending.
|
|
||||||
/// `page` is 0-indexed.
|
|
||||||
pub fn get_messages(
|
pub fn get_messages(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
@@ -319,10 +286,6 @@ pub fn get_messages(
|
|||||||
Ok(messages)
|
Ok(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a page of messages with a validated sort order.
|
|
||||||
///
|
|
||||||
/// `sort` is mapped to a static SQL fragment; user input is never interpolated
|
|
||||||
/// into the ORDER BY clause.
|
|
||||||
pub fn get_messages_sorted(
|
pub fn get_messages_sorted(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
@@ -339,7 +302,9 @@ pub fn get_messages_sorted(
|
|||||||
"date_desc" => "date_epoch DESC, uid DESC",
|
"date_desc" => "date_epoch DESC, uid DESC",
|
||||||
"date_asc" => "date_epoch ASC, uid ASC",
|
"date_asc" => "date_epoch ASC, uid ASC",
|
||||||
"sender_asc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) ASC, date_epoch DESC",
|
"sender_asc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) ASC, date_epoch DESC",
|
||||||
"sender_desc" => "LOWER(COALESCE(NULLIF(from_name, ''), from_address)) DESC, date_epoch DESC",
|
"sender_desc" => {
|
||||||
|
"LOWER(COALESCE(NULLIF(from_name, ''), from_address)) DESC, date_epoch DESC"
|
||||||
|
}
|
||||||
"subject_asc" => "LOWER(subject) ASC, date_epoch DESC",
|
"subject_asc" => "LOWER(subject) ASC, date_epoch DESC",
|
||||||
"subject_desc" => "LOWER(subject) DESC, date_epoch DESC",
|
"subject_desc" => "LOWER(subject) DESC, date_epoch DESC",
|
||||||
"size_asc" => "size ASC, date_epoch DESC",
|
"size_asc" => "size ASC, date_epoch DESC",
|
||||||
@@ -370,7 +335,6 @@ pub fn get_messages_sorted(
|
|||||||
.map_err(|e| format!("Failed to read sorted message row: {e}"))
|
.map_err(|e| format!("Failed to read sorted message row: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the total number of cached messages for a folder.
|
|
||||||
pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
|
pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT COUNT(*) FROM messages WHERE folder = ?1",
|
"SELECT COUNT(*) FROM messages WHERE folder = ?1",
|
||||||
@@ -380,9 +344,6 @@ pub fn count_messages(conn: &Connection, folder: &str) -> Result<u32, String> {
|
|||||||
.map_err(|e| format!("Failed to count messages: {e}"))
|
.map_err(|e| format!("Failed to count messages: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the total number of cached messages across all folders.
|
|
||||||
/// Used as a safe upper bound when the UI explicitly asks to materialize
|
|
||||||
/// every result of a search for a bulk operation.
|
|
||||||
pub fn count_all_messages(conn: &Connection) -> Result<usize, String> {
|
pub fn count_all_messages(conn: &Connection) -> Result<usize, String> {
|
||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
|
||||||
@@ -390,7 +351,6 @@ pub fn count_all_messages(conn: &Connection) -> Result<usize, String> {
|
|||||||
usize::try_from(count).map_err(|_| "Cached message count is out of range".to_string())
|
usize::try_from(count).map_err(|_| "Cached message count is out of range".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update only the flags column for a specific message.
|
|
||||||
pub fn update_message_flags(
|
pub fn update_message_flags(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
@@ -405,8 +365,6 @@ pub fn update_message_flags(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cache a message body (HTML and/or plain text) along with attachment
|
|
||||||
/// metadata (as a JSON string) and the raw RFC-822 headers.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn cache_message_body(
|
pub fn cache_message_body(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -423,7 +381,15 @@ pub fn cache_message_body(
|
|||||||
SET body_html = ?1, body_text = ?2, body_cached = 1,
|
SET body_html = ?1, body_text = ?2, body_cached = 1,
|
||||||
attachments_json = ?3, raw_headers = ?4, email_theme = ?5
|
attachments_json = ?3, raw_headers = ?4, email_theme = ?5
|
||||||
WHERE folder = ?6 AND uid = ?7",
|
WHERE folder = ?6 AND uid = ?7",
|
||||||
params![html, text, attachments_json, raw_headers, email_theme, folder, uid],
|
params![
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
attachments_json,
|
||||||
|
raw_headers,
|
||||||
|
email_theme,
|
||||||
|
folder,
|
||||||
|
uid
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to cache message body: {e}"))?;
|
.map_err(|e| format!("Failed to cache message body: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -443,7 +409,6 @@ pub fn update_email_theme(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached body data including attachment metadata and raw headers.
|
|
||||||
pub struct CachedBody {
|
pub struct CachedBody {
|
||||||
pub html: Option<String>,
|
pub html: Option<String>,
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
@@ -452,7 +417,6 @@ pub struct CachedBody {
|
|||||||
pub email_theme: Option<i32>,
|
pub email_theme: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the cached body if `body_cached = 1`, otherwise `None`.
|
|
||||||
pub fn get_cached_body(
|
pub fn get_cached_body(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
@@ -486,17 +450,18 @@ pub fn get_cached_body(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a single message by folder and UID, or None if not found.
|
pub fn get_single_message(
|
||||||
pub fn get_single_message(conn: &Connection, folder: &str, uid: u32) -> Result<Option<CachedMessage>, String> {
|
conn: &Connection,
|
||||||
|
folder: &str,
|
||||||
|
uid: u32,
|
||||||
|
) -> Result<Option<CachedMessage>, String> {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {MSG_SELECT_COLS}
|
"SELECT {MSG_SELECT_COLS}
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE folder = ?1 AND uid = ?2"
|
WHERE folder = ?1 AND uid = ?2"
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = conn.query_row(&sql, params![folder, uid], |row| {
|
let result = conn.query_row(&sql, params![folder, uid], |row| row_to_cached_message(row));
|
||||||
row_to_cached_message(row)
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(msg) => Ok(Some(msg)),
|
Ok(msg) => Ok(Some(msg)),
|
||||||
@@ -505,7 +470,6 @@ pub fn get_single_message(conn: &Connection, folder: &str, uid: u32) -> Result<O
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a single message by folder and UID.
|
|
||||||
pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
|
pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM messages WHERE folder = ?1 AND uid = ?2",
|
"DELETE FROM messages WHERE folder = ?1 AND uid = ?2",
|
||||||
@@ -515,9 +479,6 @@ pub fn delete_message(conn: &Connection, folder: &str, uid: u32) -> Result<(), S
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `(folder, uid)` pairs for messages that don't have a cached body,
|
|
||||||
/// ordered by date_epoch DESC (most recent first), limited to `limit`.
|
|
||||||
/// Used by the deep-index background task to know which bodies to fetch.
|
|
||||||
pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(String, u32)>, String> {
|
pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(String, u32)>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -541,8 +502,10 @@ pub fn get_unindexed_messages(conn: &Connection, limit: u32) -> Result<Vec<(Stri
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all (uid, flags_csv) pairs for a folder, for reconciliation.
|
pub fn get_all_uids_and_flags(
|
||||||
pub fn get_all_uids_and_flags(conn: &Connection, folder: &str) -> Result<Vec<(u32, String)>, String> {
|
conn: &Connection,
|
||||||
|
folder: &str,
|
||||||
|
) -> Result<Vec<(u32, String)>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT uid, flags FROM messages WHERE folder = ?1")
|
.prepare("SELECT uid, flags FROM messages WHERE folder = ?1")
|
||||||
.map_err(|e| format!("Failed to prepare get_all_uids_and_flags: {e}"))?;
|
.map_err(|e| format!("Failed to prepare get_all_uids_and_flags: {e}"))?;
|
||||||
@@ -559,17 +522,10 @@ pub fn get_all_uids_and_flags(conn: &Connection, folder: &str) -> Result<Vec<(u3
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find messages related to the given `target_message_id` for threading.
|
|
||||||
///
|
|
||||||
/// Returns messages where:
|
|
||||||
/// - `message_id` equals `target_message_id`, OR
|
|
||||||
/// - `in_reply_to` equals `target_message_id`, OR
|
|
||||||
/// - `references_header` contains `target_message_id`
|
|
||||||
pub fn get_thread_messages(
|
pub fn get_thread_messages(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
target_message_id: &str,
|
target_message_id: &str,
|
||||||
) -> Result<Vec<CachedMessage>, String> {
|
) -> Result<Vec<CachedMessage>, String> {
|
||||||
// Escape % and _ characters in the message_id to prevent LIKE injection.
|
|
||||||
let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_");
|
let escaped_target = target_message_id.replace("%", "\\%").replace("_", "\\_");
|
||||||
let like_pattern = format!("%{}%", escaped_target);
|
let like_pattern = format!("%{}%", escaped_target);
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
@@ -594,8 +550,7 @@ pub fn get_thread_messages(
|
|||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let message = row.map_err(|e| format!("Failed to read thread message row: {e}"))?;
|
let message = row.map_err(|e| format!("Failed to read thread message row: {e}"))?;
|
||||||
// The SQL LIKE narrows candidates but a Message-ID must match a complete
|
|
||||||
// token in References, never just a substring of another ID.
|
|
||||||
let exact_reference = message
|
let exact_reference = message
|
||||||
.references_header
|
.references_header
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -610,14 +565,6 @@ pub fn get_thread_messages(
|
|||||||
Ok(messages)
|
Ok(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a complete email thread by transitively walking the References chain.
|
|
||||||
///
|
|
||||||
/// Starting from `message_id` and the optional space-separated `references` string,
|
|
||||||
/// this function repeatedly queries related messages (via `get_thread_messages`) and
|
|
||||||
/// collects every message_id, in_reply_to, and references_header ID it encounters.
|
|
||||||
/// The process repeats until no new IDs are discovered (transitive closure).
|
|
||||||
///
|
|
||||||
/// The result is deduplicated by `(folder, uid)` and sorted by `date_epoch` ascending.
|
|
||||||
pub fn get_full_thread(
|
pub fn get_full_thread(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
message_id: &str,
|
message_id: &str,
|
||||||
@@ -625,7 +572,6 @@ pub fn get_full_thread(
|
|||||||
) -> Result<Vec<CachedMessage>, String> {
|
) -> Result<Vec<CachedMessage>, String> {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
// Step 1: Seed the set of known message IDs.
|
|
||||||
let mut known_ids: HashSet<String> = HashSet::new();
|
let mut known_ids: HashSet<String> = HashSet::new();
|
||||||
known_ids.insert(message_id.to_string());
|
known_ids.insert(message_id.to_string());
|
||||||
if let Some(refs) = references {
|
if let Some(refs) = references {
|
||||||
@@ -636,16 +582,11 @@ pub fn get_full_thread(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2-4: Iteratively query until no new IDs are found.
|
|
||||||
let mut processed_ids: HashSet<String> = HashSet::new();
|
let mut processed_ids: HashSet<String> = HashSet::new();
|
||||||
let mut all_messages: Vec<CachedMessage> = Vec::new();
|
let mut all_messages: Vec<CachedMessage> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Find IDs we haven't queried yet.
|
let to_query: Vec<String> = known_ids.difference(&processed_ids).cloned().collect();
|
||||||
let to_query: Vec<String> = known_ids
|
|
||||||
.difference(&processed_ids)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if to_query.is_empty() {
|
if to_query.is_empty() {
|
||||||
break;
|
break;
|
||||||
@@ -656,7 +597,6 @@ pub fn get_full_thread(
|
|||||||
|
|
||||||
let found = get_thread_messages(conn, id)?;
|
let found = get_thread_messages(conn, id)?;
|
||||||
for msg in found {
|
for msg in found {
|
||||||
// Extract IDs from each found message and add to known set.
|
|
||||||
if let Some(ref mid) = msg.message_id
|
if let Some(ref mid) = msg.message_id
|
||||||
&& !mid.is_empty()
|
&& !mid.is_empty()
|
||||||
{
|
{
|
||||||
@@ -679,19 +619,14 @@ pub fn get_full_thread(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Deduplicate by (folder, uid) pair.
|
|
||||||
let mut seen: HashSet<(String, u32)> = HashSet::new();
|
let mut seen: HashSet<(String, u32)> = HashSet::new();
|
||||||
all_messages.retain(|msg| seen.insert((msg.folder.clone(), msg.uid)));
|
all_messages.retain(|msg| seen.insert((msg.folder.clone(), msg.uid)));
|
||||||
|
|
||||||
// Step 6: Sort by date_epoch ascending.
|
|
||||||
all_messages.sort_by_key(|msg| parse_date_to_epoch(&msg.date));
|
all_messages.sort_by_key(|msg| parse_date_to_epoch(&msg.date));
|
||||||
|
|
||||||
Ok(all_messages)
|
Ok(all_messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search the SQLite message cache using LIKE for text matches across
|
|
||||||
/// subject, from_name, from_address, and to_addresses.
|
|
||||||
/// This provides comprehensive results independent of the tantivy index state.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn search_messages_sqlite(
|
pub fn search_messages_sqlite(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -711,7 +646,6 @@ pub fn search_messages_sqlite(
|
|||||||
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||||
let mut idx = 1u32;
|
let mut idx = 1u32;
|
||||||
|
|
||||||
// Exclude Trash/Spam/Junk from search results (same as tantivy).
|
|
||||||
conditions.push("LOWER(folder) NOT IN ('trash', 'spam', 'junk')".to_string());
|
conditions.push("LOWER(folder) NOT IN ('trash', 'spam', 'junk')".to_string());
|
||||||
|
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
@@ -741,7 +675,11 @@ pub fn search_messages_sqlite(
|
|||||||
} else {
|
} else {
|
||||||
conditions.push(format!("from_name LIKE ?{idx}"));
|
conditions.push(format!("from_name LIKE ?{idx}"));
|
||||||
}
|
}
|
||||||
let val = if f.contains('@') { f.to_string() } else { format!("%{f}%") };
|
let val = if f.contains('@') {
|
||||||
|
f.to_string()
|
||||||
|
} else {
|
||||||
|
format!("%{f}%")
|
||||||
|
};
|
||||||
param_values.push(Box::new(val));
|
param_values.push(Box::new(val));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
@@ -786,7 +724,7 @@ pub fn search_messages_sqlite(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = idx; // suppress unused warning
|
let _ = idx;
|
||||||
|
|
||||||
let where_clause = if conditions.is_empty() {
|
let where_clause = if conditions.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -799,12 +737,14 @@ pub fn search_messages_sqlite(
|
|||||||
FROM messages
|
FROM messages
|
||||||
{where_clause}
|
{where_clause}
|
||||||
ORDER BY date_epoch DESC
|
ORDER BY date_epoch DESC
|
||||||
LIMIT ?{}", param_values.len() + 1
|
LIMIT ?{}",
|
||||||
|
param_values.len() + 1
|
||||||
);
|
);
|
||||||
|
|
||||||
param_values.push(Box::new(limit as i64));
|
param_values.push(Box::new(limit as i64));
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|b| b.as_ref()).collect();
|
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||||
|
param_values.iter().map(|b| b.as_ref()).collect();
|
||||||
|
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(&sql)
|
.prepare(&sql)
|
||||||
@@ -829,12 +769,10 @@ mod tests {
|
|||||||
use crate::db::folders::upsert_folder;
|
use crate::db::folders::upsert_folder;
|
||||||
use crate::db::pool::open_test_db;
|
use crate::db::pool::open_test_db;
|
||||||
|
|
||||||
/// Helper: insert a folder so that the foreign key constraint is satisfied.
|
|
||||||
fn ensure_folder(conn: &Connection, name: &str) {
|
fn ensure_folder(conn: &Connection, name: &str) {
|
||||||
upsert_folder(conn, name, Some("/"), None, "", true, 0, 0, 1, 0).unwrap();
|
upsert_folder(conn, name, Some("/"), None, "", true, 0, 0, 1, 0).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: insert a sample message.
|
|
||||||
fn insert_sample(conn: &Connection, folder: &str, uid: u32, date: &str) {
|
fn insert_sample(conn: &Connection, folder: &str, uid: u32, date: &str) {
|
||||||
upsert_message(
|
upsert_message(
|
||||||
conn,
|
conn,
|
||||||
@@ -880,12 +818,10 @@ mod tests {
|
|||||||
assert!(e2 > e1, "date 2 ({e2}) should be after date 1 ({e1})");
|
assert!(e2 > e1, "date 2 ({e2}) should be after date 1 ({e1})");
|
||||||
assert!(e3 > e2, "date 3 ({e3}) should be after date 2 ({e2})");
|
assert!(e3 > e2, "date 3 ({e3}) should be after date 2 ({e2})");
|
||||||
|
|
||||||
// RFC2822 format.
|
|
||||||
let e4 = parse_date_to_epoch("Mon, 1 Jan 2024 10:00:00 +0000");
|
let e4 = parse_date_to_epoch("Mon, 1 Jan 2024 10:00:00 +0000");
|
||||||
assert!(e4 > 0, "epoch for rfc2822 date should be > 0, got {e4}");
|
assert!(e4 > 0, "epoch for rfc2822 date should be > 0, got {e4}");
|
||||||
assert_eq!(e1, e4, "ISO and RFC2822 should produce same epoch");
|
assert_eq!(e1, e4, "ISO and RFC2822 should produce same epoch");
|
||||||
|
|
||||||
// Empty returns 0.
|
|
||||||
assert_eq!(parse_date_to_epoch(""), 0);
|
assert_eq!(parse_date_to_epoch(""), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,7 +837,6 @@ mod tests {
|
|||||||
let msgs = get_messages(&conn, "INBOX", 0, 10).unwrap();
|
let msgs = get_messages(&conn, "INBOX", 0, 10).unwrap();
|
||||||
assert_eq!(msgs.len(), 3);
|
assert_eq!(msgs.len(), 3);
|
||||||
|
|
||||||
// Should be date DESC: uid 3, 2, 1.
|
|
||||||
assert_eq!(msgs[0].uid, 3);
|
assert_eq!(msgs[0].uid, 3);
|
||||||
assert_eq!(msgs[1].uid, 2);
|
assert_eq!(msgs[1].uid, 2);
|
||||||
assert_eq!(msgs[2].uid, 1);
|
assert_eq!(msgs[2].uid, 1);
|
||||||
@@ -913,7 +848,12 @@ mod tests {
|
|||||||
ensure_folder(&conn, "INBOX");
|
ensure_folder(&conn, "INBOX");
|
||||||
|
|
||||||
for uid in 1..=5 {
|
for uid in 1..=5 {
|
||||||
insert_sample(&conn, "INBOX", uid, &format!("2024-01-{:02}T10:00:00Z", uid));
|
insert_sample(
|
||||||
|
&conn,
|
||||||
|
"INBOX",
|
||||||
|
uid,
|
||||||
|
&format!("2024-01-{:02}T10:00:00Z", uid),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let page0 = get_messages(&conn, "INBOX", 0, 2).unwrap();
|
let page0 = get_messages(&conn, "INBOX", 0, 2).unwrap();
|
||||||
@@ -924,7 +864,6 @@ mod tests {
|
|||||||
assert_eq!(page1.len(), 2);
|
assert_eq!(page1.len(), 2);
|
||||||
assert_eq!(page2.len(), 1);
|
assert_eq!(page2.len(), 1);
|
||||||
|
|
||||||
// Verify no UIDs overlap between pages.
|
|
||||||
let uids0: Vec<u32> = page0.iter().map(|m| m.uid).collect();
|
let uids0: Vec<u32> = page0.iter().map(|m| m.uid).collect();
|
||||||
let uids1: Vec<u32> = page1.iter().map(|m| m.uid).collect();
|
let uids1: Vec<u32> = page1.iter().map(|m| m.uid).collect();
|
||||||
let uids2: Vec<u32> = page2.iter().map(|m| m.uid).collect();
|
let uids2: Vec<u32> = page2.iter().map(|m| m.uid).collect();
|
||||||
@@ -969,7 +908,6 @@ mod tests {
|
|||||||
ensure_folder(&conn, "INBOX");
|
ensure_folder(&conn, "INBOX");
|
||||||
insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z");
|
insert_sample(&conn, "INBOX", 1, "2024-01-01T10:00:00Z");
|
||||||
|
|
||||||
// Body should not be cached yet.
|
|
||||||
let body = get_cached_body(&conn, "INBOX", 1).unwrap();
|
let body = get_cached_body(&conn, "INBOX", 1).unwrap();
|
||||||
assert!(body.is_none());
|
assert!(body.is_none());
|
||||||
}
|
}
|
||||||
@@ -1011,10 +949,16 @@ mod tests {
|
|||||||
assert!(body.is_none());
|
assert!(body.is_none());
|
||||||
|
|
||||||
cache_message_body(
|
cache_message_body(
|
||||||
&conn, "INBOX", 1,
|
&conn,
|
||||||
Some("<h1>Test</h1>"), Some("Test"),
|
"INBOX",
|
||||||
None, None, Some(0),
|
1,
|
||||||
).unwrap();
|
Some("<h1>Test</h1>"),
|
||||||
|
Some("Test"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
update_email_theme(&conn, "INBOX", 1, 1).unwrap();
|
update_email_theme(&conn, "INBOX", 1, 1).unwrap();
|
||||||
|
|
||||||
@@ -1041,27 +985,51 @@ mod tests {
|
|||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
ensure_folder(&conn, "INBOX");
|
ensure_folder(&conn, "INBOX");
|
||||||
|
|
||||||
// Original message.
|
|
||||||
upsert_message(
|
upsert_message(
|
||||||
&conn, "INBOX", 1,
|
&conn,
|
||||||
Some("<thread-1@example.com>"), None, None,
|
"INBOX",
|
||||||
"Hello", "alice@example.com", "Alice", "[]", "[]",
|
1,
|
||||||
"2024-01-01T10:00:00Z", "", 100, false, "", None,
|
Some("<thread-1@example.com>"),
|
||||||
).unwrap();
|
None,
|
||||||
|
None,
|
||||||
|
"Hello",
|
||||||
|
"alice@example.com",
|
||||||
|
"Alice",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-01T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Reply referencing original via in_reply_to.
|
|
||||||
upsert_message(
|
upsert_message(
|
||||||
&conn, "INBOX", 2,
|
&conn,
|
||||||
|
"INBOX",
|
||||||
|
2,
|
||||||
Some("<reply-1@example.com>"),
|
Some("<reply-1@example.com>"),
|
||||||
Some("<thread-1@example.com>"),
|
Some("<thread-1@example.com>"),
|
||||||
None,
|
None,
|
||||||
"Re: Hello", "bob@example.com", "Bob", "[]", "[]",
|
"Re: Hello",
|
||||||
"2024-01-02T10:00:00Z", "", 200, false, "", None,
|
"bob@example.com",
|
||||||
).unwrap();
|
"Bob",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-02T10:00:00Z",
|
||||||
|
"",
|
||||||
|
200,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let thread = get_thread_messages(&conn, "<thread-1@example.com>").unwrap();
|
let thread = get_thread_messages(&conn, "<thread-1@example.com>").unwrap();
|
||||||
assert_eq!(thread.len(), 2);
|
assert_eq!(thread.len(), 2);
|
||||||
// ASC order: uid 1 first, uid 2 second.
|
|
||||||
assert_eq!(thread[0].uid, 1);
|
assert_eq!(thread[0].uid, 1);
|
||||||
assert_eq!(thread[1].uid, 2);
|
assert_eq!(thread[1].uid, 2);
|
||||||
}
|
}
|
||||||
@@ -1071,28 +1039,52 @@ mod tests {
|
|||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
ensure_folder(&conn, "INBOX");
|
ensure_folder(&conn, "INBOX");
|
||||||
|
|
||||||
// Original message.
|
|
||||||
upsert_message(
|
upsert_message(
|
||||||
&conn, "INBOX", 1,
|
&conn,
|
||||||
Some("<orig@example.com>"), None, None,
|
"INBOX",
|
||||||
"Hello", "alice@example.com", "Alice", "[]", "[]",
|
1,
|
||||||
"2024-01-01T10:00:00Z", "", 100, false, "", None,
|
Some("<orig@example.com>"),
|
||||||
).unwrap();
|
None,
|
||||||
|
None,
|
||||||
|
"Hello",
|
||||||
|
"alice@example.com",
|
||||||
|
"Alice",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-01T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// A message that references the original only via references_header.
|
|
||||||
upsert_message(
|
upsert_message(
|
||||||
&conn, "INBOX", 3,
|
&conn,
|
||||||
|
"INBOX",
|
||||||
|
3,
|
||||||
Some("<deep-reply@example.com>"),
|
Some("<deep-reply@example.com>"),
|
||||||
Some("<mid@example.com>"),
|
Some("<mid@example.com>"),
|
||||||
Some("<orig@example.com> <mid@example.com>"),
|
Some("<orig@example.com> <mid@example.com>"),
|
||||||
"Re: Re: Hello", "carol@example.com", "Carol", "[]", "[]",
|
"Re: Re: Hello",
|
||||||
"2024-01-03T10:00:00Z", "", 300, false, "", None,
|
"carol@example.com",
|
||||||
).unwrap();
|
"Carol",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-03T10:00:00Z",
|
||||||
|
"",
|
||||||
|
300,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let thread = get_thread_messages(&conn, "<orig@example.com>").unwrap();
|
let thread = get_thread_messages(&conn, "<orig@example.com>").unwrap();
|
||||||
assert_eq!(thread.len(), 2);
|
assert_eq!(thread.len(), 2);
|
||||||
assert_eq!(thread[0].uid, 1); // matched by message_id
|
assert_eq!(thread[0].uid, 1);
|
||||||
assert_eq!(thread[1].uid, 3); // matched by references_header LIKE
|
assert_eq!(thread[1].uid, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1101,17 +1093,45 @@ mod tests {
|
|||||||
ensure_folder(&conn, "INBOX");
|
ensure_folder(&conn, "INBOX");
|
||||||
|
|
||||||
upsert_message(
|
upsert_message(
|
||||||
&conn, "INBOX", 1,
|
&conn,
|
||||||
Some("<a@example.com>"), None, None,
|
"INBOX",
|
||||||
"Original", "alice@example.com", "Alice", "[]", "[]",
|
1,
|
||||||
"2024-01-01T10:00:00Z", "", 100, false, "", None,
|
Some("<a@example.com>"),
|
||||||
).unwrap();
|
None,
|
||||||
|
None,
|
||||||
|
"Original",
|
||||||
|
"alice@example.com",
|
||||||
|
"Alice",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-01T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
upsert_message(
|
upsert_message(
|
||||||
&conn, "INBOX", 2,
|
&conn,
|
||||||
Some("<reply@example.com>"), None, Some("<aa@example.com>"),
|
"INBOX",
|
||||||
"Unrelated", "bob@example.com", "Bob", "[]", "[]",
|
2,
|
||||||
"2024-01-02T10:00:00Z", "", 100, false, "", None,
|
Some("<reply@example.com>"),
|
||||||
).unwrap();
|
None,
|
||||||
|
Some("<aa@example.com>"),
|
||||||
|
"Unrelated",
|
||||||
|
"bob@example.com",
|
||||||
|
"Bob",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-02T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let thread = get_thread_messages(&conn, "<a@example.com>").unwrap();
|
let thread = get_thread_messages(&conn, "<a@example.com>").unwrap();
|
||||||
assert_eq!(thread.len(), 1);
|
assert_eq!(thread.len(), 1);
|
||||||
@@ -1123,17 +1143,69 @@ mod tests {
|
|||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
ensure_folder(&conn, "INBOX");
|
ensure_folder(&conn, "INBOX");
|
||||||
|
|
||||||
// Message 1: original
|
upsert_message(
|
||||||
upsert_message(&conn, "INBOX", 1, Some("<a@ex>"), None, None,
|
&conn,
|
||||||
"Hello", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap();
|
"INBOX",
|
||||||
// Message 2: reply to 1
|
1,
|
||||||
upsert_message(&conn, "INBOX", 2, Some("<b@ex>"), Some("<a@ex>"), Some("<a@ex>"),
|
Some("<a@ex>"),
|
||||||
"Re: Hello", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap();
|
None,
|
||||||
// Message 3: reply to 2, references both
|
None,
|
||||||
upsert_message(&conn, "INBOX", 3, Some("<c@ex>"), Some("<b@ex>"), Some("<a@ex> <b@ex>"),
|
"Hello",
|
||||||
"Re: Re: Hello", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap();
|
"alice@ex",
|
||||||
|
"Alice",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-01T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
upsert_message(
|
||||||
|
&conn,
|
||||||
|
"INBOX",
|
||||||
|
2,
|
||||||
|
Some("<b@ex>"),
|
||||||
|
Some("<a@ex>"),
|
||||||
|
Some("<a@ex>"),
|
||||||
|
"Re: Hello",
|
||||||
|
"bob@ex",
|
||||||
|
"Bob",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-02T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
upsert_message(
|
||||||
|
&conn,
|
||||||
|
"INBOX",
|
||||||
|
3,
|
||||||
|
Some("<c@ex>"),
|
||||||
|
Some("<b@ex>"),
|
||||||
|
Some("<a@ex> <b@ex>"),
|
||||||
|
"Re: Re: Hello",
|
||||||
|
"carol@ex",
|
||||||
|
"Carol",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-03T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Starting from message 2, should find the entire thread (1, 2, 3).
|
|
||||||
let thread = get_full_thread(&conn, "<b@ex>", Some("<a@ex>")).unwrap();
|
let thread = get_full_thread(&conn, "<b@ex>", Some("<a@ex>")).unwrap();
|
||||||
assert_eq!(thread.len(), 3);
|
assert_eq!(thread.len(), 3);
|
||||||
assert_eq!(thread[0].uid, 1);
|
assert_eq!(thread[0].uid, 1);
|
||||||
@@ -1146,14 +1218,67 @@ mod tests {
|
|||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
ensure_folder(&conn, "INBOX");
|
ensure_folder(&conn, "INBOX");
|
||||||
|
|
||||||
upsert_message(&conn, "INBOX", 1, Some("<root@ex>"), None, None,
|
upsert_message(
|
||||||
"Start", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap();
|
&conn,
|
||||||
upsert_message(&conn, "INBOX", 2, Some("<mid@ex>"), Some("<root@ex>"), Some("<root@ex>"),
|
"INBOX",
|
||||||
"Re: Start", "bob@ex", "Bob", "[]", "[]", "2024-01-02T10:00:00Z", "", 100, false, "", None).unwrap();
|
1,
|
||||||
upsert_message(&conn, "INBOX", 3, Some("<leaf@ex>"), Some("<mid@ex>"), Some("<root@ex> <mid@ex>"),
|
Some("<root@ex>"),
|
||||||
"Re: Re: Start", "carol@ex", "Carol", "[]", "[]", "2024-01-03T10:00:00Z", "", 100, false, "", None).unwrap();
|
None,
|
||||||
|
None,
|
||||||
|
"Start",
|
||||||
|
"alice@ex",
|
||||||
|
"Alice",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-01T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
upsert_message(
|
||||||
|
&conn,
|
||||||
|
"INBOX",
|
||||||
|
2,
|
||||||
|
Some("<mid@ex>"),
|
||||||
|
Some("<root@ex>"),
|
||||||
|
Some("<root@ex>"),
|
||||||
|
"Re: Start",
|
||||||
|
"bob@ex",
|
||||||
|
"Bob",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-02T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
upsert_message(
|
||||||
|
&conn,
|
||||||
|
"INBOX",
|
||||||
|
3,
|
||||||
|
Some("<leaf@ex>"),
|
||||||
|
Some("<mid@ex>"),
|
||||||
|
Some("<root@ex> <mid@ex>"),
|
||||||
|
"Re: Re: Start",
|
||||||
|
"carol@ex",
|
||||||
|
"Carol",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-03T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Starting from the leaf message, should still find entire thread.
|
|
||||||
let thread = get_full_thread(&conn, "<leaf@ex>", Some("<root@ex> <mid@ex>")).unwrap();
|
let thread = get_full_thread(&conn, "<leaf@ex>", Some("<root@ex> <mid@ex>")).unwrap();
|
||||||
assert_eq!(thread.len(), 3);
|
assert_eq!(thread.len(), 3);
|
||||||
}
|
}
|
||||||
@@ -1163,8 +1288,26 @@ mod tests {
|
|||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
ensure_folder(&conn, "INBOX");
|
ensure_folder(&conn, "INBOX");
|
||||||
|
|
||||||
upsert_message(&conn, "INBOX", 1, Some("<solo@ex>"), None, None,
|
upsert_message(
|
||||||
"Solo", "alice@ex", "Alice", "[]", "[]", "2024-01-01T10:00:00Z", "", 100, false, "", None).unwrap();
|
&conn,
|
||||||
|
"INBOX",
|
||||||
|
1,
|
||||||
|
Some("<solo@ex>"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"Solo",
|
||||||
|
"alice@ex",
|
||||||
|
"Alice",
|
||||||
|
"[]",
|
||||||
|
"[]",
|
||||||
|
"2024-01-01T10:00:00Z",
|
||||||
|
"",
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let thread = get_full_thread(&conn, "<solo@ex>", None).unwrap();
|
let thread = get_full_thread(&conn, "<solo@ex>", None).unwrap();
|
||||||
assert_eq!(thread.len(), 1);
|
assert_eq!(thread.len(), 1);
|
||||||
@@ -1172,8 +1315,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached storage is an estimate while folders are still being synchronized.
|
|
||||||
/// None distinguishes an empty cache from cached zero-byte messages.
|
|
||||||
pub fn cached_storage_bytes(conn: &Connection) -> Result<Option<u64>, String> {
|
pub fn cached_storage_bytes(conn: &Connection) -> Result<Option<u64>, String> {
|
||||||
conn.query_row("SELECT SUM(size) FROM messages", [], |row| row.get(0))
|
conn.query_row("SELECT SUM(size) FROM messages", [], |row| row.get(0))
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ pub struct UpdateNotificationPreferences {
|
|||||||
pub dnd_end: Option<String>,
|
pub dnd_end: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the singleton notification preferences row.
|
|
||||||
/// Returns sensible defaults if the row does not yet exist.
|
|
||||||
pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, String> {
|
pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, String> {
|
||||||
let result = conn.query_row(
|
let result = conn.query_row(
|
||||||
"SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1",
|
"SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1",
|
||||||
@@ -41,7 +39,15 @@ pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, Str
|
|||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((enabled_int, sound_int, folders_json, dnd_enabled_int, dnd_start, dnd_end, updated_at)) => {
|
Ok((
|
||||||
|
enabled_int,
|
||||||
|
sound_int,
|
||||||
|
folders_json,
|
||||||
|
dnd_enabled_int,
|
||||||
|
dnd_start,
|
||||||
|
dnd_end,
|
||||||
|
updated_at,
|
||||||
|
)) => {
|
||||||
let folders: Vec<String> = serde_json::from_str(&folders_json)
|
let folders: Vec<String> = serde_json::from_str(&folders_json)
|
||||||
.map_err(|e| format!("Failed to parse folders JSON: {e}"))?;
|
.map_err(|e| format!("Failed to parse folders JSON: {e}"))?;
|
||||||
Ok(NotificationPreferences {
|
Ok(NotificationPreferences {
|
||||||
@@ -67,13 +73,10 @@ pub fn get_preferences(conn: &Connection) -> Result<NotificationPreferences, Str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the singleton notification preferences row.
|
|
||||||
/// Only provided fields are changed. Returns the updated preferences.
|
|
||||||
pub fn update_preferences(
|
pub fn update_preferences(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data: &UpdateNotificationPreferences,
|
data: &UpdateNotificationPreferences,
|
||||||
) -> Result<NotificationPreferences, String> {
|
) -> Result<NotificationPreferences, String> {
|
||||||
// Ensure the row exists.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)",
|
"INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)",
|
||||||
[],
|
[],
|
||||||
@@ -95,8 +98,8 @@ pub fn update_preferences(
|
|||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
if let Some(ref folders) = data.folders {
|
if let Some(ref folders) = data.folders {
|
||||||
let folders_json =
|
let folders_json = serde_json::to_string(folders)
|
||||||
serde_json::to_string(folders).map_err(|e| format!("Failed to serialize folders: {e}"))?;
|
.map_err(|e| format!("Failed to serialize folders: {e}"))?;
|
||||||
sets.push(format!("folders = ?{idx}"));
|
sets.push(format!("folders = ?{idx}"));
|
||||||
values.push(Box::new(folders_json));
|
values.push(Box::new(folders_json));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
@@ -107,13 +110,17 @@ pub fn update_preferences(
|
|||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
if let Some(ref dnd_start) = data.dnd_start {
|
if let Some(ref dnd_start) = data.dnd_start {
|
||||||
if !valid_clock(dnd_start) { return Err("Invalid DND start time".to_string()); }
|
if !valid_clock(dnd_start) {
|
||||||
|
return Err("Invalid DND start time".to_string());
|
||||||
|
}
|
||||||
sets.push(format!("dnd_start = ?{idx}"));
|
sets.push(format!("dnd_start = ?{idx}"));
|
||||||
values.push(Box::new(dnd_start.clone()));
|
values.push(Box::new(dnd_start.clone()));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
if let Some(ref dnd_end) = data.dnd_end {
|
if let Some(ref dnd_end) = data.dnd_end {
|
||||||
if !valid_clock(dnd_end) { return Err("Invalid DND end time".to_string()); }
|
if !valid_clock(dnd_end) {
|
||||||
|
return Err("Invalid DND end time".to_string());
|
||||||
|
}
|
||||||
sets.push(format!("dnd_end = ?{idx}"));
|
sets.push(format!("dnd_end = ?{idx}"));
|
||||||
values.push(Box::new(dnd_end.clone()));
|
values.push(Box::new(dnd_end.clone()));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
@@ -128,8 +135,7 @@ pub fn update_preferences(
|
|||||||
let sql = format!("UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}");
|
let sql = format!("UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}");
|
||||||
values.push(Box::new(1_i32));
|
values.push(Box::new(1_i32));
|
||||||
|
|
||||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect();
|
||||||
values.iter().map(|v| v.as_ref()).collect();
|
|
||||||
|
|
||||||
conn.execute(&sql, params_refs.as_slice())
|
conn.execute(&sql, params_refs.as_slice())
|
||||||
.map_err(|e| format!("Failed to update notification preferences: {e}"))?;
|
.map_err(|e| format!("Failed to update notification preferences: {e}"))?;
|
||||||
@@ -138,7 +144,9 @@ pub fn update_preferences(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn valid_clock(value: &str) -> bool {
|
fn valid_clock(value: &str) -> bool {
|
||||||
let Some((hour, minute)) = value.split_once(':') else { return false; };
|
let Some((hour, minute)) = value.split_once(':') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
matches!((hour.parse::<u8>(), minute.parse::<u8>()), (Ok(h), Ok(m)) if h < 24 && m < 60 && hour.len() == 2 && minute.len() == 2)
|
matches!((hour.parse::<u8>(), minute.parse::<u8>()), (Ok(h), Ok(m)) if h < 24 && m < 60 && hour.len() == 2 && minute.len() == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,10 +205,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(prefs.enabled);
|
assert!(prefs.enabled);
|
||||||
assert_eq!(
|
assert_eq!(prefs.folders, vec!["INBOX".to_string(), "Sent".to_string()]);
|
||||||
prefs.folders,
|
|
||||||
vec!["INBOX".to_string(), "Sent".to_string()]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+1
-25
@@ -3,7 +3,6 @@ use std::path::Path;
|
|||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
/// Owned database context for short blocking jobs. Never hold SQLite on a Tokio worker.
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct UserDb {
|
pub(crate) struct UserDb {
|
||||||
data_dir: String,
|
data_dir: String,
|
||||||
@@ -32,7 +31,6 @@ impl UserDb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All migration scripts in order. Each entry is (version, sql).
|
|
||||||
const MIGRATIONS: &[(u32, &str)] = &[
|
const MIGRATIONS: &[(u32, &str)] = &[
|
||||||
(1, include_str!("../../migrations/V001__initial_schema.sql")),
|
(1, include_str!("../../migrations/V001__initial_schema.sql")),
|
||||||
(
|
(
|
||||||
@@ -199,20 +197,11 @@ const MIGRATIONS: &[(u32, &str)] = &[
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Run any pending migrations based on SQLite's `user_version` PRAGMA.
|
|
||||||
///
|
|
||||||
/// If refinery has previously run migrations (indicated by the presence of
|
|
||||||
/// `refinery_schema_history`), we sync `user_version` to match the highest
|
|
||||||
/// refinery version before checking for new migrations. This avoids re-running
|
|
||||||
/// migrations that refinery already applied.
|
|
||||||
fn run_migrations(conn: &Connection) -> Result<(), String> {
|
fn run_migrations(conn: &Connection) -> Result<(), String> {
|
||||||
let mut current: u32 = conn
|
let mut current: u32 = conn
|
||||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||||
.map_err(|e| format!("Failed to read user_version: {e}"))?;
|
.map_err(|e| format!("Failed to read user_version: {e}"))?;
|
||||||
|
|
||||||
// Sync with refinery's migration state so we never re-apply migrations
|
|
||||||
// that refinery already applied (refinery updates its own history table
|
|
||||||
// but doesn't touch the user_version pragma).
|
|
||||||
let refinery_max: Option<u32> = conn
|
let refinery_max: Option<u32> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT MAX(version) FROM refinery_schema_history",
|
"SELECT MAX(version) FROM refinery_schema_history",
|
||||||
@@ -241,10 +230,6 @@ fn run_migrations(conn: &Connection) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the per-user SQLite database at `{data_dir}/{user_hash}/db.sqlite`.
|
|
||||||
///
|
|
||||||
/// Creates the directory tree if it doesn't exist, enables WAL journal mode
|
|
||||||
/// and foreign key enforcement, then runs any pending migrations.
|
|
||||||
pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, String> {
|
pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, String> {
|
||||||
let dir = Path::new(data_dir).join(user_hash);
|
let dir = Path::new(data_dir).join(user_hash);
|
||||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?;
|
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?;
|
||||||
@@ -252,23 +237,17 @@ pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, Strin
|
|||||||
let db_path = dir.join("db.sqlite");
|
let db_path = dir.join("db.sqlite");
|
||||||
let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
|
let conn = Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
|
||||||
|
|
||||||
// Enable WAL mode for better concurrent read performance.
|
|
||||||
conn.execute_batch("PRAGMA journal_mode=WAL;")
|
conn.execute_batch("PRAGMA journal_mode=WAL;")
|
||||||
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
|
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
|
||||||
|
|
||||||
// Enable foreign key constraint enforcement.
|
|
||||||
conn.execute_batch("PRAGMA foreign_keys=ON;")
|
conn.execute_batch("PRAGMA foreign_keys=ON;")
|
||||||
.map_err(|e| format!("Failed to enable foreign keys: {e}"))?;
|
.map_err(|e| format!("Failed to enable foreign keys: {e}"))?;
|
||||||
|
|
||||||
// Run any pending schema migrations.
|
|
||||||
run_migrations(&conn)?;
|
run_migrations(&conn)?;
|
||||||
|
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens an in-memory SQLite database with all migration scripts applied.
|
|
||||||
/// Used exclusively by tests so every test starts with a clean, fully-migrated
|
|
||||||
/// schema without touching the filesystem.
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn open_test_db() -> Connection {
|
pub fn open_test_db() -> Connection {
|
||||||
let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite");
|
let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite");
|
||||||
@@ -289,7 +268,6 @@ mod tests {
|
|||||||
fn test_open_test_db_has_tables() {
|
fn test_open_test_db_has_tables() {
|
||||||
let conn = open_test_db();
|
let conn = open_test_db();
|
||||||
|
|
||||||
// Verify the three expected tables exist.
|
|
||||||
let tables: Vec<String> = conn
|
let tables: Vec<String> = conn
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -312,11 +290,9 @@ mod tests {
|
|||||||
|
|
||||||
let conn = open_user_db(data_dir, "abc123").unwrap();
|
let conn = open_user_db(data_dir, "abc123").unwrap();
|
||||||
|
|
||||||
// The file should exist on disk.
|
|
||||||
let db_file = tmp.path().join("abc123").join("db.sqlite");
|
let db_file = tmp.path().join("abc123").join("db.sqlite");
|
||||||
assert!(db_file.exists());
|
assert!(db_file.exists());
|
||||||
|
|
||||||
// Foreign keys should be enabled.
|
|
||||||
let fk: i32 = conn
|
let fk: i32 = conn
|
||||||
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
|
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -347,7 +323,7 @@ mod async_tests {
|
|||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
ready.await.unwrap();
|
ready.await.unwrap();
|
||||||
// On a current-thread runtime, an inline DB job prevents this send.
|
|
||||||
release.send(()).unwrap();
|
release.send(()).unwrap();
|
||||||
job.await.unwrap().unwrap();
|
job.await.unwrap().unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const ROLES: &[&str] = &["sent", "drafts", "trash", "junk", "archive"];
|
const ROLES: &[&str] = &["sent", "drafts", "trash", "junk", "archive"];
|
||||||
@@ -78,7 +78,11 @@ pub fn update_all(conn: &Connection, folders: &SpecialFolders) -> Result<Special
|
|||||||
get_all(conn)
|
get_all(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rename_folder_reference(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> {
|
pub fn rename_folder_reference(
|
||||||
|
conn: &Connection,
|
||||||
|
old_name: &str,
|
||||||
|
new_name: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2",
|
"UPDATE special_folders SET folder_name = ?1, updated_at = datetime('now') WHERE folder_name = ?2",
|
||||||
params![new_name, old_name],
|
params![new_name, old_name],
|
||||||
@@ -88,8 +92,11 @@ pub fn rename_folder_reference(conn: &Connection, old_name: &str, new_name: &str
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_folder_reference(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
pub fn clear_folder_reference(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||||
conn.execute("DELETE FROM special_folders WHERE folder_name = ?1", [folder_name])
|
conn.execute(
|
||||||
.map_err(|e| format!("Failed to clear special folder reference: {e}"))?;
|
"DELETE FROM special_folders WHERE folder_name = ?1",
|
||||||
|
[folder_name],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to clear special folder reference: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-50
@@ -1,11 +1,10 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{Connection, params};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::db::messages::CachedMessage;
|
use crate::db::messages::CachedMessage;
|
||||||
|
|
||||||
/// A tag record, mirroring the `tags` table.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct Tag {
|
pub struct Tag {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -17,7 +16,6 @@ pub struct Tag {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lightweight tag info returned alongside messages.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct MessageTag {
|
pub struct MessageTag {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -25,13 +23,11 @@ pub struct MessageTag {
|
|||||||
pub color: String,
|
pub color: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Create a new tag.
|
|
||||||
pub fn imap_keyword_for_tag_id(id: &str) -> String {
|
pub fn imap_keyword_for_tag_id(id: &str) -> String {
|
||||||
format!("RustBox-{}", id.chars().filter(|ch| *ch != '-').collect::<String>())
|
format!(
|
||||||
|
"RustBox-{}",
|
||||||
|
id.chars().filter(|ch| *ch != '-').collect::<String>()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<(), String> {
|
pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<(), String> {
|
||||||
@@ -44,7 +40,6 @@ pub fn create_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all tags with their message counts.
|
|
||||||
pub fn list_tags(conn: &Connection) -> Result<Vec<Tag>, String> {
|
pub fn list_tags(conn: &Connection) -> Result<Vec<Tag>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -81,22 +76,33 @@ pub fn get_tag(conn: &Connection, id: &str) -> Result<Option<Tag>, String> {
|
|||||||
Ok(tags.drain(..).find(|tag| tag.id == id))
|
Ok(tags.drain(..).find(|tag| tag.id == id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_message_refs_by_tag(conn: &Connection, tag_id: &str) -> Result<Vec<(u32, String)>, String> {
|
pub fn get_message_refs_by_tag(
|
||||||
let mut stmt = conn.prepare("SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?1")
|
conn: &Connection,
|
||||||
|
tag_id: &str,
|
||||||
|
) -> Result<Vec<(u32, String)>, String> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT message_uid, message_folder FROM message_tags WHERE tag_id = ?1")
|
||||||
.map_err(|e| format!("Failed to prepare tag message refs: {e}"))?;
|
.map_err(|e| format!("Failed to prepare tag message refs: {e}"))?;
|
||||||
let rows = stmt.query_map(params![tag_id], |row| Ok((row.get(0)?, row.get(1)?)))
|
let rows = stmt
|
||||||
|
.query_map(params![tag_id], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
.map_err(|e| format!("Failed to query tag message refs: {e}"))?;
|
.map_err(|e| format!("Failed to query tag message refs: {e}"))?;
|
||||||
let mut refs = Vec::new();
|
let mut refs = Vec::new();
|
||||||
for row in rows { refs.push(row.map_err(|e| format!("Failed to read tag message ref: {e}"))?); }
|
for row in rows {
|
||||||
|
refs.push(row.map_err(|e| format!("Failed to read tag message ref: {e}"))?);
|
||||||
|
}
|
||||||
Ok(refs)
|
Ok(refs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import RustBox-owned IMAP keyword flags into local message/tag relations.
|
pub fn import_imap_keywords(
|
||||||
/// This intentionally only adds remotely-present labels; absence is not treated as
|
conn: &Connection,
|
||||||
/// deletion because some servers/folders do not expose persistent custom flags.
|
uid: u32,
|
||||||
pub fn import_imap_keywords(conn: &Connection, uid: u32, folder: &str, flags: &[String]) -> Result<usize, String> {
|
folder: &str,
|
||||||
|
flags: &[String],
|
||||||
|
) -> Result<usize, String> {
|
||||||
let keyword_set: std::collections::HashSet<&str> = flags.iter().map(String::as_str).collect();
|
let keyword_set: std::collections::HashSet<&str> = flags.iter().map(String::as_str).collect();
|
||||||
if !keyword_set.iter().any(|flag| flag.starts_with("RustBox-")) { return Ok(0); }
|
if !keyword_set.iter().any(|flag| flag.starts_with("RustBox-")) {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
let tags = list_tags(conn)?;
|
let tags = list_tags(conn)?;
|
||||||
let mut imported = 0usize;
|
let mut imported = 0usize;
|
||||||
for tag in tags {
|
for tag in tags {
|
||||||
@@ -108,14 +114,6 @@ pub fn import_imap_keywords(conn: &Connection, uid: u32, folder: &str, flags: &[
|
|||||||
Ok(imported)
|
Ok(imported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Reconcile RustBox-owned IMAP keyword flags for one message.
|
|
||||||
///
|
|
||||||
/// This is intentionally stronger than `import_imap_keywords`: callers must
|
|
||||||
/// use it only after confirming that the selected mailbox supports persistent
|
|
||||||
/// custom keywords and that `flags` is the complete authoritative FLAGS set.
|
|
||||||
/// Remotely present RustBox keywords are added locally and remotely absent
|
|
||||||
/// RustBox keywords are removed locally.
|
|
||||||
pub fn reconcile_imap_keywords(
|
pub fn reconcile_imap_keywords(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
@@ -153,7 +151,6 @@ pub fn reconcile_imap_keywords(
|
|||||||
Ok((added, removed))
|
Ok((added, removed))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a tag's name and/or color.
|
|
||||||
pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<bool, String> {
|
pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Result<bool, String> {
|
||||||
let updated = conn
|
let updated = conn
|
||||||
.execute(
|
.execute(
|
||||||
@@ -164,7 +161,6 @@ pub fn update_tag(conn: &Connection, id: &str, name: &str, color: &str) -> Resul
|
|||||||
Ok(updated > 0)
|
Ok(updated > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a tag. CASCADE handles junction rows.
|
|
||||||
pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> {
|
pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||||
let deleted = conn
|
let deleted = conn
|
||||||
.execute("DELETE FROM tags WHERE id = ?1", params![id])
|
.execute("DELETE FROM tags WHERE id = ?1", params![id])
|
||||||
@@ -172,7 +168,6 @@ pub fn delete_tag(conn: &Connection, id: &str) -> Result<bool, String> {
|
|||||||
Ok(deleted > 0)
|
Ok(deleted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a tag to a message (INSERT OR IGNORE to avoid duplicates).
|
|
||||||
pub fn add_tag_to_message(
|
pub fn add_tag_to_message(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
tag_id: &str,
|
tag_id: &str,
|
||||||
@@ -187,7 +182,6 @@ pub fn add_tag_to_message(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a tag from a message.
|
|
||||||
pub fn remove_tag_from_message(
|
pub fn remove_tag_from_message(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
tag_id: &str,
|
tag_id: &str,
|
||||||
@@ -203,8 +197,11 @@ pub fn remove_tag_from_message(
|
|||||||
Ok(deleted > 0)
|
Ok(deleted > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all tags for a specific message.
|
pub fn get_message_tags(
|
||||||
pub fn get_message_tags(conn: &Connection, uid: u32, folder: &str) -> Result<Vec<MessageTag>, String> {
|
conn: &Connection,
|
||||||
|
uid: u32,
|
||||||
|
folder: &str,
|
||||||
|
) -> Result<Vec<MessageTag>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT t.id, t.name, t.color
|
"SELECT t.id, t.name, t.color
|
||||||
@@ -232,7 +229,6 @@ pub fn get_message_tags(conn: &Connection, uid: u32, folder: &str) -> Result<Vec
|
|||||||
Ok(tags)
|
Ok(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Batch-fetch tags for multiple messages (efficient for list view).
|
|
||||||
pub fn get_tags_for_messages(
|
pub fn get_tags_for_messages(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
messages: &[(u32, &str)],
|
messages: &[(u32, &str)],
|
||||||
@@ -241,12 +237,16 @@ pub fn get_tags_for_messages(
|
|||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a query with placeholders for all message refs.
|
|
||||||
// Using a single query with OR conditions for reasonable batch sizes.
|
|
||||||
let conditions: Vec<String> = messages
|
let conditions: Vec<String> = messages
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, _)| format!("(mt.message_uid = ?{} AND mt.message_folder = ?{})", i * 2 + 1, i * 2 + 2))
|
.map(|(i, _)| {
|
||||||
|
format!(
|
||||||
|
"(mt.message_uid = ?{} AND mt.message_folder = ?{})",
|
||||||
|
i * 2 + 1,
|
||||||
|
i * 2 + 2
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
@@ -267,7 +267,8 @@ pub fn get_tags_for_messages(
|
|||||||
param_values.push(Box::new(uid));
|
param_values.push(Box::new(uid));
|
||||||
param_values.push(Box::new(folder.to_string()));
|
param_values.push(Box::new(folder.to_string()));
|
||||||
}
|
}
|
||||||
let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect();
|
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
|
||||||
|
param_values.iter().map(|p| p.as_ref()).collect();
|
||||||
|
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params_ref.as_slice(), |row| {
|
.query_map(params_ref.as_slice(), |row| {
|
||||||
@@ -290,7 +291,6 @@ pub fn get_tags_for_messages(
|
|||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get messages by tag (paginated), ordered by date descending.
|
|
||||||
pub fn get_messages_by_tag(
|
pub fn get_messages_by_tag(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
tag_id: &str,
|
tag_id: &str,
|
||||||
@@ -342,7 +342,6 @@ pub fn get_messages_by_tag(
|
|||||||
Ok(messages)
|
Ok(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count messages with a given tag.
|
|
||||||
pub fn count_messages_by_tag(conn: &Connection, tag_id: &str) -> Result<u32, String> {
|
pub fn count_messages_by_tag(conn: &Connection, tag_id: &str) -> Result<u32, String> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT COUNT(*) FROM message_tags WHERE tag_id = ?1",
|
"SELECT COUNT(*) FROM message_tags WHERE tag_id = ?1",
|
||||||
@@ -420,14 +419,21 @@ mod tests {
|
|||||||
|
|
||||||
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
||||||
add_tag_to_message(&conn, "t2", 1, "INBOX").unwrap();
|
add_tag_to_message(&conn, "t2", 1, "INBOX").unwrap();
|
||||||
// Duplicate should be ignored
|
|
||||||
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
add_tag_to_message(&conn, "t1", 1, "INBOX").unwrap();
|
||||||
|
|
||||||
let tags = get_message_tags(&conn, 1, "INBOX").unwrap();
|
let tags = get_message_tags(&conn, 1, "INBOX").unwrap();
|
||||||
assert_eq!(tags.len(), 2);
|
assert_eq!(tags.len(), 2);
|
||||||
|
|
||||||
let tag_list = list_tags(&conn).unwrap();
|
let tag_list = list_tags(&conn).unwrap();
|
||||||
assert_eq!(tag_list.iter().find(|t| t.name == "Urgent").unwrap().message_count, 1);
|
assert_eq!(
|
||||||
|
tag_list
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.name == "Urgent")
|
||||||
|
.unwrap()
|
||||||
|
.message_count,
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -483,13 +489,9 @@ mod tests {
|
|||||||
add_tag_to_message(&conn, "t1", 7, "INBOX").unwrap();
|
add_tag_to_message(&conn, "t1", 7, "INBOX").unwrap();
|
||||||
|
|
||||||
let keyword_two = imap_keyword_for_tag_id("t2");
|
let keyword_two = imap_keyword_for_tag_id("t2");
|
||||||
let (added, removed) = reconcile_imap_keywords(
|
let (added, removed) =
|
||||||
&conn,
|
reconcile_imap_keywords(&conn, 7, "INBOX", &["\\Seen".to_string(), keyword_two])
|
||||||
7,
|
.unwrap();
|
||||||
"INBOX",
|
|
||||||
&["\\Seen".to_string(), keyword_two],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(added, 1);
|
assert_eq!(added, 1);
|
||||||
assert_eq!(removed, 1);
|
assert_eq!(removed, 1);
|
||||||
@@ -497,5 +499,4 @@ mod tests {
|
|||||||
assert_eq!(tags.len(), 1);
|
assert_eq!(tags.len(), 1);
|
||||||
assert_eq!(tags[0].id, "t2");
|
assert_eq!(tags[0].id, "t2");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+1
-70
@@ -53,8 +53,6 @@ static TD_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<td\b[^>]*>"#
|
|||||||
|
|
||||||
static DIV_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<div\b[^>]*>"#).unwrap());
|
static DIV_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<div\b[^>]*>"#).unwrap());
|
||||||
|
|
||||||
/// Compiled word-boundary regexes for each named CSS color, ensuring exact
|
|
||||||
/// matching so that e.g. "white" does not match inside "whitesmoke".
|
|
||||||
static NAMED_COLOR_REGEXES: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new(|| {
|
static NAMED_COLOR_REGEXES: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new(|| {
|
||||||
NAMED_COLORS
|
NAMED_COLORS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -65,7 +63,6 @@ static NAMED_COLOR_REGEXES: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new
|
|||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Named CSS colors mapped to RGB values.
|
|
||||||
type NamedColorList = Vec<(&'static str, (u8, u8, u8))>;
|
type NamedColorList = Vec<(&'static str, (u8, u8, u8))>;
|
||||||
static NAMED_COLORS: LazyLock<NamedColorList> = LazyLock::new(|| {
|
static NAMED_COLORS: LazyLock<NamedColorList> = LazyLock::new(|| {
|
||||||
vec![
|
vec![
|
||||||
@@ -132,15 +129,6 @@ impl ToSql for EmailTheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect whether an email's HTML is light, dark, transparent, or adaptive.
|
|
||||||
///
|
|
||||||
/// Detection priority:
|
|
||||||
/// 1. If the email has `@media(prefers-color-scheme:dark)` rules with background
|
|
||||||
/// overrides, classify as Adaptive (it handles dark mode natively).
|
|
||||||
/// 2. Extract background colors from body, table, td, div elements (inline styles,
|
|
||||||
/// style tags, bgcolor attrs). Group by luminance (light vs dark). If one group
|
|
||||||
/// dominates (>= 50% of total weight), return that group's classification.
|
|
||||||
/// 3. If no backgrounds found, return Transparent.
|
|
||||||
pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
|
pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
|
||||||
if html.trim().is_empty() {
|
if html.trim().is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -148,9 +136,6 @@ pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
|
|||||||
|
|
||||||
let decoded = decode_quoted_printable(html);
|
let decoded = decode_quoted_printable(html);
|
||||||
|
|
||||||
// Check for @media(prefers-color-scheme:dark) with background overrides.
|
|
||||||
// These emails handle their own dark adaptation — classify as Adaptive
|
|
||||||
// so the frontend sets color-scheme and lets the email CSS handle it.
|
|
||||||
if has_dark_mode_media_query(&decoded) {
|
if has_dark_mode_media_query(&decoded) {
|
||||||
return Some(EmailTheme::Adaptive);
|
return Some(EmailTheme::Adaptive);
|
||||||
}
|
}
|
||||||
@@ -164,8 +149,6 @@ pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
|
|||||||
classify_by_luminance_groups(&backgrounds)
|
classify_by_luminance_groups(&backgrounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the HTML has a @media(prefers-color-scheme:dark) block that sets
|
|
||||||
/// background colors, indicating the email handles dark mode natively.
|
|
||||||
fn has_dark_mode_media_query(html: &str) -> bool {
|
fn has_dark_mode_media_query(html: &str) -> bool {
|
||||||
let lower = html.to_lowercase();
|
let lower = html.to_lowercase();
|
||||||
for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) {
|
for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) {
|
||||||
@@ -177,8 +160,6 @@ fn has_dark_mode_media_query(html: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Group all extracted colors into "light" and "dark" buckets by their
|
|
||||||
/// luminance, then check which bucket dominates.
|
|
||||||
fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme> {
|
fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme> {
|
||||||
let mut light_weight: f32 = 0.0;
|
let mut light_weight: f32 = 0.0;
|
||||||
let mut dark_weight: f32 = 0.0;
|
let mut dark_weight: f32 = 0.0;
|
||||||
@@ -205,20 +186,12 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme
|
|||||||
let light_coverage = light_weight / total_weight;
|
let light_coverage = light_weight / total_weight;
|
||||||
let dark_coverage = dark_weight / total_weight;
|
let dark_coverage = dark_weight / total_weight;
|
||||||
|
|
||||||
// Require at least 50% of weighted colors to agree
|
|
||||||
if light_coverage >= 0.5 {
|
if light_coverage >= 0.5 {
|
||||||
// If the only signal is from a wrapper table/td with white background
|
|
||||||
// and there's no body-level background, treat as transparent.
|
|
||||||
// A lone "white" on a wrapper element is usually just a reset, not
|
|
||||||
// intentional theming.
|
|
||||||
if !has_body_background && is_only_white_wrapper(colors) {
|
if !has_body_background && is_only_white_wrapper(colors) {
|
||||||
return Some(EmailTheme::Transparent);
|
return Some(EmailTheme::Transparent);
|
||||||
}
|
}
|
||||||
Some(EmailTheme::Light)
|
Some(EmailTheme::Light)
|
||||||
} else if dark_coverage >= 0.5 {
|
} else if dark_coverage >= 0.5 {
|
||||||
// Guard against tiny accent elements being the only signal:
|
|
||||||
// if total weight is very low (only 1-2 low-weight elements like td),
|
|
||||||
// that's likely an accent, not a real background.
|
|
||||||
if total_weight <= 1.0 && colors.len() <= 2 && !has_body_background {
|
if total_weight <= 1.0 && colors.len() <= 2 && !has_body_background {
|
||||||
Some(EmailTheme::Transparent)
|
Some(EmailTheme::Transparent)
|
||||||
} else {
|
} else {
|
||||||
@@ -229,19 +202,15 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if all color signals are just white (#ffffff or named "white") on
|
|
||||||
/// wrapper elements (table or td) without any body-level background. This
|
|
||||||
/// usually indicates a boilerplate reset rather than intentional light theming.
|
|
||||||
fn is_only_white_wrapper(colors: &[BackgroundColor]) -> bool {
|
fn is_only_white_wrapper(colors: &[BackgroundColor]) -> bool {
|
||||||
if colors.is_empty() {
|
if colors.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for bg in colors {
|
for bg in colors {
|
||||||
// If there's a body-level background, it's intentional
|
|
||||||
if bg.element_weight >= 1.0 {
|
if bg.element_weight >= 1.0 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Only consider pure white as a wrapper reset
|
|
||||||
let c = bg.color.trim().to_lowercase();
|
let c = bg.color.trim().to_lowercase();
|
||||||
if c != "#ffffff" && c != "white" {
|
if c != "#ffffff" && c != "white" {
|
||||||
return false;
|
return false;
|
||||||
@@ -273,21 +242,16 @@ fn extract_background_colors(html: &str) -> Vec<BackgroundColor> {
|
|||||||
extract_style_tag_colors(html, &mut colors);
|
extract_style_tag_colors(html, &mut colors);
|
||||||
extract_bgcolor_attrs(html, &mut colors);
|
extract_bgcolor_attrs(html, &mut colors);
|
||||||
|
|
||||||
// Deduplicate: if the same color appears on the same element from both
|
|
||||||
// bgcolor and style attributes, keep only the one with higher weight.
|
|
||||||
deduplicate_colors(&mut colors);
|
deduplicate_colors(&mut colors);
|
||||||
|
|
||||||
colors
|
colors
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove duplicate color entries that likely come from the same element
|
|
||||||
/// having both bgcolor="X" and style="background-color:X".
|
|
||||||
fn deduplicate_colors(colors: &mut Vec<BackgroundColor>) {
|
fn deduplicate_colors(colors: &mut Vec<BackgroundColor>) {
|
||||||
if colors.len() <= 1 {
|
if colors.len() <= 1 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track seen (normalized_color, weight) pairs and remove exact duplicates
|
|
||||||
let mut seen: HashSet<String> = HashSet::new();
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
colors.retain(|bg| {
|
colors.retain(|bg| {
|
||||||
let key = format!("{}:{:.1}", bg.color, bg.element_weight);
|
let key = format!("{}:{:.1}", bg.color, bg.element_weight);
|
||||||
@@ -334,7 +298,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract from body{...} rules
|
|
||||||
if let Some(body_cap) = BODY_CSS_RE.captures(style_content) {
|
if let Some(body_cap) = BODY_CSS_RE.captures(style_content) {
|
||||||
let body_styles = &body_cap[1];
|
let body_styles = &body_cap[1];
|
||||||
if body_styles.to_lowercase().contains("background") {
|
if body_styles.to_lowercase().contains("background") {
|
||||||
@@ -342,7 +305,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract from class rules that mention background
|
|
||||||
for class_cap in CLASS_CSS_RE.captures_iter(style_content) {
|
for class_cap in CLASS_CSS_RE.captures_iter(style_content) {
|
||||||
let class_styles = &class_cap[1];
|
let class_styles = &class_cap[1];
|
||||||
if class_styles.to_lowercase().contains("background") {
|
if class_styles.to_lowercase().contains("background") {
|
||||||
@@ -350,7 +312,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract from element-level rules (td{...}, table{...}, etc.)
|
|
||||||
for elem_cap in ELEM_CSS_RE.captures_iter(style_content) {
|
for elem_cap in ELEM_CSS_RE.captures_iter(style_content) {
|
||||||
let tag = elem_cap[1].to_lowercase();
|
let tag = elem_cap[1].to_lowercase();
|
||||||
let elem_styles = &elem_cap[2];
|
let elem_styles = &elem_cap[2];
|
||||||
@@ -368,12 +329,9 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract color values only from background/background-color properties in a CSS rule body.
|
|
||||||
fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec<BackgroundColor>) {
|
fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec<BackgroundColor>) {
|
||||||
let lower = css.to_lowercase();
|
let lower = css.to_lowercase();
|
||||||
|
|
||||||
// Only extract colors from background/background-color property values,
|
|
||||||
// not from the entire CSS rule (which may contain color, border-color, etc.)
|
|
||||||
for bg_cap in BG_PROP_RE.captures_iter(&lower) {
|
for bg_cap in BG_PROP_RE.captures_iter(&lower) {
|
||||||
let bg_value = bg_cap[1].trim();
|
let bg_value = bg_cap[1].trim();
|
||||||
|
|
||||||
@@ -425,8 +383,6 @@ fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec<Backgrou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for named colors using word-boundary matching to avoid
|
|
||||||
// false positives (e.g. "white" matching inside "whitesmoke").
|
|
||||||
for (name, re) in NAMED_COLOR_REGEXES.iter() {
|
for (name, re) in NAMED_COLOR_REGEXES.iter() {
|
||||||
if re.is_match(bg_value) {
|
if re.is_match(bg_value) {
|
||||||
colors.push(BackgroundColor {
|
colors.push(BackgroundColor {
|
||||||
@@ -462,7 +418,6 @@ fn extract_bgcolor_attrs(html: &str, colors: &mut Vec<BackgroundColor>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A color extracted from an inline style, with its alpha value.
|
|
||||||
struct ExtractedColor {
|
struct ExtractedColor {
|
||||||
color: String,
|
color: String,
|
||||||
alpha: f32,
|
alpha: f32,
|
||||||
@@ -478,8 +433,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
|
|||||||
.map(|m| m.as_str().to_lowercase())
|
.map(|m| m.as_str().to_lowercase())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Only extract colors from background/background-color property values,
|
|
||||||
// not from the entire style string (which may contain border-color, color, etc.)
|
|
||||||
for bg_cap in BG_PROP_RE.captures_iter(&style_value) {
|
for bg_cap in BG_PROP_RE.captures_iter(&style_value) {
|
||||||
let bg_value = bg_cap[1].trim();
|
let bg_value = bg_cap[1].trim();
|
||||||
|
|
||||||
@@ -512,8 +465,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for named colors using word-boundary matching to avoid
|
|
||||||
// false positives (e.g. "white" matching inside "whitesmoke").
|
|
||||||
for (name, re) in NAMED_COLOR_REGEXES.iter() {
|
for (name, re) in NAMED_COLOR_REGEXES.iter() {
|
||||||
if re.is_match(bg_value) {
|
if re.is_match(bg_value) {
|
||||||
colors.push(ExtractedColor {
|
colors.push(ExtractedColor {
|
||||||
@@ -529,8 +480,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
|
|||||||
colors
|
colors
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the alpha channel from an 8-digit hex color (e.g. `#rrggbbaa`).
|
|
||||||
/// Returns 1.0 for 3/6-digit hex or if parsing fails.
|
|
||||||
fn parse_hex_alpha(color: &str) -> f32 {
|
fn parse_hex_alpha(color: &str) -> f32 {
|
||||||
let c = color.trim().to_lowercase();
|
let c = color.trim().to_lowercase();
|
||||||
if let Some(hex) = c.strip_prefix('#') {
|
if let Some(hex) = c.strip_prefix('#') {
|
||||||
@@ -545,7 +494,6 @@ fn parse_hex_alpha(color: &str) -> f32 {
|
|||||||
1.0
|
1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an alpha string that may be a decimal (`0.5`) or percentage (`50%`).
|
|
||||||
fn parse_alpha_str(s: &str) -> f32 {
|
fn parse_alpha_str(s: &str) -> f32 {
|
||||||
if let Some(pct) = s.strip_suffix('%') {
|
if let Some(pct) = s.strip_suffix('%') {
|
||||||
pct.parse::<f32>().unwrap_or(100.0) / 100.0
|
pct.parse::<f32>().unwrap_or(100.0) / 100.0
|
||||||
@@ -577,7 +525,6 @@ fn normalize_color(color: &str) -> String {
|
|||||||
}
|
}
|
||||||
6 => c,
|
6 => c,
|
||||||
8 => {
|
8 => {
|
||||||
// 8-digit hex: strip alpha channel
|
|
||||||
format!("#{}", &hex[0..6])
|
format!("#{}", &hex[0..6])
|
||||||
}
|
}
|
||||||
_ => c,
|
_ => c,
|
||||||
@@ -595,7 +542,6 @@ fn calculate_luminance(color: &str) -> f64 {
|
|||||||
fn parse_color(color: &str) -> (u8, u8, u8) {
|
fn parse_color(color: &str) -> (u8, u8, u8) {
|
||||||
let c = color.trim().to_lowercase();
|
let c = color.trim().to_lowercase();
|
||||||
|
|
||||||
// Check named colors first
|
|
||||||
for (name, rgb) in NAMED_COLORS.iter() {
|
for (name, rgb) in NAMED_COLORS.iter() {
|
||||||
if c == *name {
|
if c == *name {
|
||||||
return *rgb;
|
return *rgb;
|
||||||
@@ -629,7 +575,6 @@ fn parse_color(color: &str) -> (u8, u8, u8) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown color - default to white (high luminance = light, safe fallback)
|
|
||||||
(255, 255, 255)
|
(255, 255, 255)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,7 +631,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_luminance_grouping_mixed_light_colors() {
|
fn test_luminance_grouping_mixed_light_colors() {
|
||||||
// Multiple light colors should still classify as Light
|
|
||||||
let html = r#"<html>
|
let html = r#"<html>
|
||||||
<body style="background-color: #ffffff">
|
<body style="background-color: #ffffff">
|
||||||
<table style="background-color: #FAF9F5">
|
<table style="background-color: #FAF9F5">
|
||||||
@@ -700,29 +644,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_accent_td_not_treated_as_background() {
|
fn test_accent_td_not_treated_as_background() {
|
||||||
// A single td with a dark accent color shouldn't classify the email as dark
|
|
||||||
let html = r##"<html><body><table><tr><td bgcolor="#2672ec">Click here</td></tr></table></body></html>"##;
|
let html = r##"<html><body><table><tr><td bgcolor="#2672ec">Click here</td></tr></table></body></html>"##;
|
||||||
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_deduplicate_bgcolor_and_style() {
|
fn test_deduplicate_bgcolor_and_style() {
|
||||||
// Same color from both bgcolor and style on same td should be deduplicated
|
|
||||||
let html = r##"<html><body><table><tr><td bgcolor="#2672ec" style="background-color:#2672ec">Click</td></tr></table></body></html>"##;
|
let html = r##"<html><body><table><tr><td bgcolor="#2672ec" style="background-color:#2672ec">Click</td></tr></table></body></html>"##;
|
||||||
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lone_white_wrapper_table_is_transparent() {
|
fn test_lone_white_wrapper_table_is_transparent() {
|
||||||
// A wrapper table with background-color:white but no body background
|
|
||||||
// should be treated as transparent (boilerplate reset)
|
|
||||||
let html = r#"<html><body><table style="background-color:white"><tr><td>Content</td></tr></table></body></html>"#;
|
let html = r#"<html><body><table style="background-color:white"><tr><td>Content</td></tr></table></body></html>"#;
|
||||||
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_named_color_white() {
|
fn test_named_color_white() {
|
||||||
// Body with white background is intentional
|
|
||||||
let html = r#"<html><body style="background-color: white">Content</body></html>"#;
|
let html = r#"<html><body style="background-color: white">Content</body></html>"#;
|
||||||
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
|
||||||
}
|
}
|
||||||
@@ -760,8 +699,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_white_does_not_match_whitesmoke() {
|
fn test_white_does_not_match_whitesmoke() {
|
||||||
// "whitesmoke" is a distinct named color (very light gray, RGB 245,245,245).
|
|
||||||
// It must NOT be misidentified as "white".
|
|
||||||
let html = r#"<html><body style="background-color: whitesmoke">Content</body></html>"#;
|
let html = r#"<html><body style="background-color: whitesmoke">Content</body></html>"#;
|
||||||
let colors = extract_background_colors(html);
|
let colors = extract_background_colors(html);
|
||||||
assert_eq!(colors.len(), 1);
|
assert_eq!(colors.len(), 1);
|
||||||
@@ -810,7 +747,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_named_color_in_style_tag_exact_match() {
|
fn test_named_color_in_style_tag_exact_match() {
|
||||||
// Ensure style-tag extraction also uses exact matching
|
|
||||||
let html = r#"<html><head><style>body { background-color: whitesmoke; }</style></head><body>Content</body></html>"#;
|
let html = r#"<html><head><style>body { background-color: whitesmoke; }</style></head><body>Content</body></html>"#;
|
||||||
let colors = extract_background_colors(html);
|
let colors = extract_background_colors(html);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -826,8 +762,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rgba_half_alpha_reduced_weight() {
|
fn test_rgba_half_alpha_reduced_weight() {
|
||||||
// rgba(0,0,0,0.5) on body should have weight 1.0 * 0.5 = 0.5
|
|
||||||
// compared to rgb(0,0,0) which would have weight 1.0
|
|
||||||
let html_rgba =
|
let html_rgba =
|
||||||
r#"<html><body style="background-color: rgba(0,0,0,0.5)">Content</body></html>"#;
|
r#"<html><body style="background-color: rgba(0,0,0,0.5)">Content</body></html>"#;
|
||||||
let html_rgb = r#"<html><body style="background-color: rgb(0,0,0)">Content</body></html>"#;
|
let html_rgb = r#"<html><body style="background-color: rgb(0,0,0)">Content</body></html>"#;
|
||||||
@@ -851,7 +785,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rgba_fully_transparent_ignored() {
|
fn test_rgba_fully_transparent_ignored() {
|
||||||
// rgba(0,0,0,0) should be skipped entirely (alpha < 0.1)
|
|
||||||
let html = r#"<html><body style="background-color: rgba(0,0,0,0)">Content</body></html>"#;
|
let html = r#"<html><body style="background-color: rgba(0,0,0,0)">Content</body></html>"#;
|
||||||
let colors = extract_background_colors(html);
|
let colors = extract_background_colors(html);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -863,7 +796,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rgba_full_alpha_same_as_rgb() {
|
fn test_rgba_full_alpha_same_as_rgb() {
|
||||||
// rgba(255,255,255,1.0) should behave identically to rgb(255,255,255)
|
|
||||||
let html_rgba =
|
let html_rgba =
|
||||||
r#"<html><body style="background-color: rgba(255,255,255,1.0)">Content</body></html>"#;
|
r#"<html><body style="background-color: rgba(255,255,255,1.0)">Content</body></html>"#;
|
||||||
let html_rgb =
|
let html_rgb =
|
||||||
@@ -885,7 +817,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rgba_in_style_tag() {
|
fn test_rgba_in_style_tag() {
|
||||||
// RGBA in a <style> block should also have reduced weight
|
|
||||||
let html = r#"<html><head><style>body { background-color: rgba(0,0,0,0.5); }</style></head><body>Content</body></html>"#;
|
let html = r#"<html><head><style>body { background-color: rgba(0,0,0,0.5); }</style></head><body>Content</body></html>"#;
|
||||||
let colors = extract_background_colors(html);
|
let colors = extract_background_colors(html);
|
||||||
assert_eq!(colors.len(), 1);
|
assert_eq!(colors.len(), 1);
|
||||||
|
|||||||
+6
-14
@@ -2,13 +2,11 @@ use axum::http::StatusCode;
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
/// Structured JSON error envelope returned to clients.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ErrorEnvelope {
|
struct ErrorEnvelope {
|
||||||
error: ErrorBody,
|
error: ErrorBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body of the error envelope.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ErrorBody {
|
struct ErrorBody {
|
||||||
code: String,
|
code: String,
|
||||||
@@ -16,29 +14,25 @@ struct ErrorBody {
|
|||||||
status: u16,
|
status: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Application-level error type that converts into an Axum response
|
|
||||||
/// with a structured JSON error envelope.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
/// Internal server error (500).
|
|
||||||
InternalError(String),
|
InternalError(String),
|
||||||
/// Resource not found (404).
|
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
/// Unauthorized access (401).
|
|
||||||
Unauthorized(String),
|
Unauthorized(String),
|
||||||
/// Bad request (400).
|
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
/// Authenticated but forbidden by policy (403).
|
|
||||||
Forbidden(String),
|
Forbidden(String),
|
||||||
/// Service unavailable (503).
|
|
||||||
ServiceUnavailable(String),
|
ServiceUnavailable(String),
|
||||||
/// Request payload is larger than the configured mail limit (413).
|
|
||||||
PayloadTooLarge(String),
|
PayloadTooLarge(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
impl AppError {
|
||||||
/// Returns the HTTP status code for this error variant.
|
|
||||||
fn status_code(&self) -> StatusCode {
|
fn status_code(&self) -> StatusCode {
|
||||||
match self {
|
match self {
|
||||||
AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -51,7 +45,6 @@ impl AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the error code string for this error variant.
|
|
||||||
fn error_code(&self) -> &'static str {
|
fn error_code(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
AppError::InternalError(_) => "INTERNAL_ERROR",
|
AppError::InternalError(_) => "INTERNAL_ERROR",
|
||||||
@@ -64,7 +57,6 @@ impl AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the human-readable message for this error variant.
|
|
||||||
fn message(&self) -> &str {
|
fn message(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
AppError::InternalError(msg)
|
AppError::InternalError(msg)
|
||||||
|
|||||||
+3
-80
@@ -19,38 +19,24 @@ pub(crate) use super::connection::connect;
|
|||||||
#[path = "mock.rs"]
|
#[path = "mock.rs"]
|
||||||
pub mod mock;
|
pub mod mock;
|
||||||
|
|
||||||
/// Abstraction over IMAP operations.
|
|
||||||
///
|
|
||||||
/// Every method receives explicit connection parameters so that the trait
|
|
||||||
/// keeps its API independent of connection management.
|
|
||||||
///
|
|
||||||
/// The `Send + Sync` bounds allow implementations to be shared across
|
|
||||||
/// Tokio tasks and stored in `Arc`.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[allow(clippy::double_must_use)]
|
#[allow(clippy::double_must_use)]
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ImapClient: Send + Sync {
|
pub trait ImapClient: Send + Sync {
|
||||||
/// Return the capabilities advertised by the authenticated IMAP server.
|
|
||||||
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError>;
|
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError>;
|
||||||
|
|
||||||
/// Drop reusable request-path connections for this account. The default
|
|
||||||
/// implementation is a no-op for test/alternate clients.
|
|
||||||
async fn reset_connections(&self, _creds: &ImapCredentials) -> Result<(), ImapError> {
|
async fn reset_connections(&self, _creds: &ImapCredentials) -> Result<(), ImapError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all folders (mailboxes) on the server.
|
|
||||||
async fn list_folders(&self, creds: &ImapCredentials) -> Result<Vec<ImapFolder>, ImapError>;
|
async fn list_folders(&self, creds: &ImapCredentials) -> Result<Vec<ImapFolder>, ImapError>;
|
||||||
|
|
||||||
/// Perform a lightweight `SELECT` on a folder to get its status
|
|
||||||
/// (UIDVALIDITY, EXISTS count, UIDNEXT) without fetching any messages.
|
|
||||||
async fn folder_status(
|
async fn folder_status(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
) -> Result<FolderStatus, ImapError>;
|
) -> Result<FolderStatus, ImapError>;
|
||||||
|
|
||||||
/// Fetch message headers (envelopes) for a range of UIDs in a folder.
|
|
||||||
async fn fetch_headers(
|
async fn fetch_headers(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -58,7 +44,6 @@ pub trait ImapClient: Send + Sync {
|
|||||||
uid_range: &str,
|
uid_range: &str,
|
||||||
) -> Result<Vec<ImapMessageHeader>, ImapError>;
|
) -> Result<Vec<ImapMessageHeader>, ImapError>;
|
||||||
|
|
||||||
/// Fetch the full body of a single message by UID.
|
|
||||||
async fn fetch_body(
|
async fn fetch_body(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -66,7 +51,6 @@ pub trait ImapClient: Send + Sync {
|
|||||||
uid: u32,
|
uid: u32,
|
||||||
) -> Result<ImapMessageBody, ImapError>;
|
) -> Result<ImapMessageBody, ImapError>;
|
||||||
|
|
||||||
/// Fetch the complete RFC822 message bytes for source/download operations.
|
|
||||||
async fn fetch_raw_message(
|
async fn fetch_raw_message(
|
||||||
&self,
|
&self,
|
||||||
_creds: &ImapCredentials,
|
_creds: &ImapCredentials,
|
||||||
@@ -79,7 +63,6 @@ pub trait ImapClient: Send + Sync {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add flags to a message (IMAP +FLAGS).
|
|
||||||
async fn add_flags(
|
async fn add_flags(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -88,7 +71,6 @@ pub trait ImapClient: Send + Sync {
|
|||||||
flags: &[&str],
|
flags: &[&str],
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Remove flags from a message (IMAP -FLAGS).
|
|
||||||
async fn remove_flags(
|
async fn remove_flags(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -97,7 +79,6 @@ pub trait ImapClient: Send + Sync {
|
|||||||
flags: &[&str],
|
flags: &[&str],
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Set (replace) the flags on a message.
|
|
||||||
async fn set_flags(
|
async fn set_flags(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -106,7 +87,6 @@ pub trait ImapClient: Send + Sync {
|
|||||||
flags: &[&str],
|
flags: &[&str],
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Move a message from one folder to another.
|
|
||||||
async fn move_message(
|
async fn move_message(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -115,7 +95,6 @@ pub trait ImapClient: Send + Sync {
|
|||||||
to_folder: &str,
|
to_folder: &str,
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Copy a message to another folder without removing the source.
|
|
||||||
async fn copy_message(
|
async fn copy_message(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -124,13 +103,10 @@ pub trait ImapClient: Send + Sync {
|
|||||||
to_folder: &str,
|
to_folder: &str,
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Mark all messages in a folder as read.
|
|
||||||
async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
|
async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Permanently remove every message from a folder.
|
|
||||||
async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
|
async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Permanently remove a message that has the `\Deleted` flag.
|
|
||||||
async fn expunge_message(
|
async fn expunge_message(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -138,7 +114,6 @@ pub trait ImapClient: Send + Sync {
|
|||||||
uid: u32,
|
uid: u32,
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Append a raw RFC822 message to a folder (e.g. saving sent mail to "Sent").
|
|
||||||
async fn append_message(
|
async fn append_message(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -147,11 +122,6 @@ pub trait ImapClient: Send + Sync {
|
|||||||
flags: &[&str],
|
flags: &[&str],
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Append a message and resolve the server-assigned UID without guessing.
|
|
||||||
///
|
|
||||||
/// `identity_header` and `identity_value` must uniquely identify this
|
|
||||||
/// application-owned snapshot. Implementations only accept a UID at or
|
|
||||||
/// above the mailbox UIDNEXT observed before APPEND.
|
|
||||||
async fn append_message_resolved_uid(
|
async fn append_message_resolved_uid(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -162,14 +132,12 @@ pub trait ImapClient: Send + Sync {
|
|||||||
identity_value: &str,
|
identity_value: &str,
|
||||||
) -> Result<(u32, u32), ImapError>;
|
) -> Result<(u32, u32), ImapError>;
|
||||||
|
|
||||||
/// Create a new folder (mailbox) and subscribe to it.
|
|
||||||
async fn create_folder(
|
async fn create_folder(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
folder_name: &str,
|
folder_name: &str,
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Rename an existing folder.
|
|
||||||
async fn rename_folder(
|
async fn rename_folder(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -177,14 +145,12 @@ pub trait ImapClient: Send + Sync {
|
|||||||
to: &str,
|
to: &str,
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Permanently delete a folder (mailbox).
|
|
||||||
async fn delete_folder(
|
async fn delete_folder(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
folder_name: &str,
|
folder_name: &str,
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Subscribe to or unsubscribe from a folder.
|
|
||||||
async fn subscribe_folder(
|
async fn subscribe_folder(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -192,24 +158,18 @@ pub trait ImapClient: Send + Sync {
|
|||||||
subscribe: bool,
|
subscribe: bool,
|
||||||
) -> Result<(), ImapError>;
|
) -> Result<(), ImapError>;
|
||||||
|
|
||||||
/// Fetch only UIDs and FLAGS for all messages in a folder.
|
|
||||||
/// Used for periodic reconciliation to detect flag changes and deletions.
|
|
||||||
async fn fetch_uids_and_flags(
|
async fn fetch_uids_and_flags(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
) -> Result<Vec<(u32, Vec<String>)>, ImapError>;
|
) -> Result<Vec<(u32, Vec<String>)>, ImapError>;
|
||||||
|
|
||||||
/// Lightweight STATUS command to get folder metadata without SELECT.
|
|
||||||
/// Returns UIDVALIDITY, EXISTS, UIDNEXT, UNSEEN, and HIGHESTMODSEQ.
|
|
||||||
async fn folder_status_extended(
|
async fn folder_status_extended(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
) -> Result<FolderStatusExtended, ImapError>;
|
) -> Result<FolderStatusExtended, ImapError>;
|
||||||
|
|
||||||
/// Fetch only messages whose flags changed since `since_modseq` using CONDSTORE.
|
|
||||||
/// Returns (vec of (uid, flags), new_highest_modseq).
|
|
||||||
async fn fetch_changed_flags(
|
async fn fetch_changed_flags(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -217,19 +177,14 @@ pub trait ImapClient: Send + Sync {
|
|||||||
since_modseq: u64,
|
since_modseq: u64,
|
||||||
) -> Result<(Vec<(u32, Vec<String>)>, u64), ImapError>;
|
) -> Result<(Vec<(u32, Vec<String>)>, u64), ImapError>;
|
||||||
|
|
||||||
/// Fetch mailbox quota via IMAP GETQUOTAROOT.
|
|
||||||
/// Returns `None` if the server doesn't support quotas.
|
|
||||||
async fn get_quota(&self, creds: &ImapCredentials) -> Result<Option<MailboxQuota>, ImapError>;
|
async fn get_quota(&self, creds: &ImapCredentials) -> Result<Option<MailboxQuota>, ImapError>;
|
||||||
|
|
||||||
/// Fetch the total size of all messages in a folder via UID FETCH 1:* (RFC822.SIZE).
|
|
||||||
async fn fetch_folder_size(
|
async fn fetch_folder_size(
|
||||||
&self,
|
&self,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
) -> Result<u64, ImapError>;
|
) -> Result<u64, ImapError>;
|
||||||
|
|
||||||
/// Return whether the selected mailbox allows creating persistent custom keywords.
|
|
||||||
/// An absent PERMANENTFLAGS response is treated according to RFC 3501 as permissive.
|
|
||||||
async fn folder_keyword_support(
|
async fn folder_keyword_support(
|
||||||
&self,
|
&self,
|
||||||
_creds: &ImapCredentials,
|
_creds: &ImapCredentials,
|
||||||
@@ -239,21 +194,11 @@ pub trait ImapClient: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Real implementation backed by async-imap
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Production IMAP client that uses `async-imap` and `mail-parser`.
|
|
||||||
///
|
|
||||||
/// Read operations reuse bounded, exclusively leased authenticated sessions.
|
|
||||||
/// Writes retain dedicated connections for now.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct RealImapClient {
|
pub struct RealImapClient {
|
||||||
pool: super::pool::ConnectionPool,
|
pool: super::pool::ConnectionPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Trait implementation -------------------------------------------------
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ImapClient for RealImapClient {
|
impl ImapClient for RealImapClient {
|
||||||
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError> {
|
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError> {
|
||||||
@@ -363,9 +308,6 @@ impl ImapClient for RealImapClient {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let fetches = {
|
let fetches = {
|
||||||
// Fetch ENVELOPE, FLAGS, BODYSTRUCTURE, RFC822.SIZE, and threading headers.
|
|
||||||
// We only fetch Message-ID, In-Reply-To, and References (a few bytes per message)
|
|
||||||
// rather than full raw headers, to keep bulk syncs lightweight.
|
|
||||||
let mut fetch_stream = session
|
let mut fetch_stream = session
|
||||||
.uid_fetch(
|
.uid_fetch(
|
||||||
uid_range,
|
uid_range,
|
||||||
@@ -391,7 +333,6 @@ impl ImapClient for RealImapClient {
|
|||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse threading headers from the small HEADER.FIELDS response.
|
|
||||||
let raw_header_bytes = fetch.header();
|
let raw_header_bytes = fetch.header();
|
||||||
let parsed_threading = raw_header_bytes
|
let parsed_threading = raw_header_bytes
|
||||||
.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
|
.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
|
||||||
@@ -429,9 +370,6 @@ impl ImapClient for RealImapClient {
|
|||||||
|
|
||||||
(subject, from, to, cc, date)
|
(subject, from, to, cc, date)
|
||||||
} else {
|
} else {
|
||||||
// No envelope — we can't fill subject/from/to/date from the
|
|
||||||
// small threading-only header fetch, so leave them empty.
|
|
||||||
// They'll be populated when the user opens the message body.
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
folder = %folder,
|
folder = %folder,
|
||||||
@@ -440,7 +378,6 @@ impl ImapClient for RealImapClient {
|
|||||||
(None, vec![], vec![], vec![], None)
|
(None, vec![], vec![], vec![], None)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract threading headers from the small HEADER.FIELDS response.
|
|
||||||
let message_id = parsed_threading
|
let message_id = parsed_threading
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.message_id().map(|s| format!("<{s}>")));
|
.and_then(|p| p.message_id().map(|s| format!("<{s}>")));
|
||||||
@@ -460,7 +397,6 @@ impl ImapClient for RealImapClient {
|
|||||||
.or_else(|| val.as_text().map(|s| format!("<{s}>")))
|
.or_else(|| val.as_text().map(|s| format!("<{s}>")))
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect Outlook/Exchange reaction emails from headers.
|
|
||||||
let reaction = raw_header_bytes.and_then(|raw| {
|
let reaction = raw_header_bytes.and_then(|raw| {
|
||||||
let header_str = std::str::from_utf8(raw).ok()?;
|
let header_str = std::str::from_utf8(raw).ok()?;
|
||||||
let lower = header_str.to_lowercase();
|
let lower = header_str.to_lowercase();
|
||||||
@@ -587,7 +523,6 @@ impl ImapClient for RealImapClient {
|
|||||||
|
|
||||||
let mut attachments = Vec::new();
|
let mut attachments = Vec::new();
|
||||||
|
|
||||||
// Collect explicit attachments.
|
|
||||||
for attachment in parsed.attachments() {
|
for attachment in parsed.attachments() {
|
||||||
let filename: Option<String> = attachment.attachment_name().map(|s| s.to_string());
|
let filename: Option<String> = attachment.attachment_name().map(|s| s.to_string());
|
||||||
let content_type: String = attachment.content_type().map_or_else(
|
let content_type: String = attachment.content_type().map_or_else(
|
||||||
@@ -614,13 +549,11 @@ impl ImapClient for RealImapClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also collect inline parts with Content-ID (e.g. embedded images
|
|
||||||
// referenced via cid: URLs in the HTML body).
|
|
||||||
for part in parsed.parts.iter() {
|
for part in parsed.parts.iter() {
|
||||||
if part.content_id().is_none() {
|
if part.content_id().is_none() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Skip if this is a text/html or text/plain body part.
|
|
||||||
let is_text = part.content_type().is_some_and(|ct| ct.ctype() == "text");
|
let is_text = part.content_type().is_some_and(|ct| ct.ctype() == "text");
|
||||||
if is_text {
|
if is_text {
|
||||||
continue;
|
continue;
|
||||||
@@ -630,7 +563,7 @@ impl ImapClient for RealImapClient {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.trim_matches(|c| c == '<' || c == '>')
|
.trim_matches(|c| c == '<' || c == '>')
|
||||||
.to_string();
|
.to_string();
|
||||||
// Skip if we already captured this part via attachments().
|
|
||||||
if attachments
|
if attachments
|
||||||
.iter()
|
.iter()
|
||||||
.any(|a| a.content_id.as_deref() == Some(&cid))
|
.any(|a| a.content_id.as_deref() == Some(&cid))
|
||||||
@@ -658,7 +591,6 @@ impl ImapClient for RealImapClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract raw headers from the RFC 822 message.
|
|
||||||
let raw_str = String::from_utf8_lossy(raw);
|
let raw_str = String::from_utf8_lossy(raw);
|
||||||
let raw_headers = raw_str
|
let raw_headers = raw_str
|
||||||
.split_once("\r\n\r\n")
|
.split_once("\r\n\r\n")
|
||||||
@@ -780,7 +712,6 @@ impl ImapClient for RealImapClient {
|
|||||||
.await
|
.await
|
||||||
.map_err(map_imap_error)?;
|
.map_err(map_imap_error)?;
|
||||||
|
|
||||||
// Consume the stream to completion so the command finishes.
|
|
||||||
while let Some(result) = store_stream.next().await {
|
while let Some(result) = store_stream.next().await {
|
||||||
result.map_err(map_imap_error)?;
|
result.map_err(map_imap_error)?;
|
||||||
}
|
}
|
||||||
@@ -803,12 +734,9 @@ impl ImapClient for RealImapClient {
|
|||||||
|
|
||||||
let uid_str = uid.to_string();
|
let uid_str = uid.to_string();
|
||||||
|
|
||||||
// Try UID MOVE first; fall back to COPY + DELETE + EXPUNGE if the
|
|
||||||
// server does not support the MOVE extension.
|
|
||||||
match session.uid_mv(&uid_str, to_folder).await {
|
match session.uid_mv(&uid_str, to_folder).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(async_imap::error::Error::No(_) | async_imap::error::Error::Bad(_)) => {
|
Err(async_imap::error::Error::No(_) | async_imap::error::Error::Bad(_)) => {
|
||||||
// Fallback: COPY, then flag \Deleted, then EXPUNGE.
|
|
||||||
session
|
session
|
||||||
.uid_copy(&uid_str, to_folder)
|
.uid_copy(&uid_str, to_folder)
|
||||||
.await
|
.await
|
||||||
@@ -907,7 +835,6 @@ impl ImapClient for RealImapClient {
|
|||||||
|
|
||||||
let uid_str = uid.to_string();
|
let uid_str = uid.to_string();
|
||||||
|
|
||||||
// Mark the message as \Deleted.
|
|
||||||
{
|
{
|
||||||
let mut store_stream = session
|
let mut store_stream = session
|
||||||
.uid_store(&uid_str, "+FLAGS (\\Deleted)")
|
.uid_store(&uid_str, "+FLAGS (\\Deleted)")
|
||||||
@@ -918,7 +845,6 @@ impl ImapClient for RealImapClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try UID EXPUNGE for precision; fall back to EXPUNGE.
|
|
||||||
let uid_expunge_ok = {
|
let uid_expunge_ok = {
|
||||||
match session.uid_expunge(&uid_str).await {
|
match session.uid_expunge(&uid_str).await {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
@@ -1214,8 +1140,7 @@ impl ImapClient for RealImapClient {
|
|||||||
.run_command("GETQUOTAROOT INBOX")
|
.run_command("GETQUOTAROOT INBOX")
|
||||||
.await
|
.await
|
||||||
.map_err(map_imap_error)?;
|
.map_err(map_imap_error)?;
|
||||||
// Matching tagged OK/NO/BAD completes the command. Transport failures
|
|
||||||
// and incomplete replies still discard the session.
|
|
||||||
let (quota, reusable) = tokio::time::timeout(std::time::Duration::from_secs(15), async {
|
let (quota, reusable) = tokio::time::timeout(std::time::Duration::from_secs(15), async {
|
||||||
let mut quota = None;
|
let mut quota = None;
|
||||||
while let Some(response) = session.read_response().await {
|
while let Some(response) = session.read_response().await {
|
||||||
@@ -1277,8 +1202,6 @@ impl ImapClient for RealImapClient {
|
|||||||
|
|
||||||
let mut total: u64 = 0;
|
let mut total: u64 = 0;
|
||||||
|
|
||||||
// Require a tagged completion: async-imap's FETCH stream hides tagged
|
|
||||||
// NO/BAD and can end at EOF, which is unsafe when returning a pooled session.
|
|
||||||
let fetch_result = tokio::time::timeout(std::time::Duration::from_secs(60), async {
|
let fetch_result = tokio::time::timeout(std::time::Duration::from_secs(60), async {
|
||||||
use async_imap::imap_proto::{Response, Status, types::AttributeValue};
|
use async_imap::imap_proto::{Response, Status, types::AttributeValue};
|
||||||
let req_id = session
|
let req_id = session
|
||||||
|
|||||||
+1
-20
@@ -1,7 +1,6 @@
|
|||||||
use super::error::ImapError;
|
use super::error::ImapError;
|
||||||
use super::types::ImapCredentials;
|
use super::types::ImapCredentials;
|
||||||
|
|
||||||
// Counts connection attempts by credential identity for isolated regression tests.
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static CONNECTION_COUNTS: std::sync::LazyLock<
|
static CONNECTION_COUNTS: std::sync::LazyLock<
|
||||||
std::sync::Mutex<std::collections::HashMap<[u8; 32], usize>>,
|
std::sync::Mutex<std::collections::HashMap<[u8; 32], usize>>,
|
||||||
@@ -16,14 +15,6 @@ pub(super) fn connection_count(creds: &ImapCredentials) -> usize {
|
|||||||
.unwrap_or(&0)
|
.unwrap_or(&0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Connection helper ----------------------------------------------------
|
|
||||||
|
|
||||||
/// Establish an authenticated IMAP session.
|
|
||||||
///
|
|
||||||
/// Returns a `Session` over a TLS stream (when `creds.tls` is true) or a
|
|
||||||
/// plain TCP stream. Because the two stream types are different concrete
|
|
||||||
/// types we use an enum wrapper that implements the traits `async-imap`
|
|
||||||
/// requires (`tokio::io::AsyncRead + AsyncWrite + Unpin + Debug`).
|
|
||||||
pub(crate) async fn connect(
|
pub(crate) async fn connect(
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
) -> Result<async_imap::Session<ImapStream>, ImapError> {
|
) -> Result<async_imap::Session<ImapStream>, ImapError> {
|
||||||
@@ -36,7 +27,7 @@ pub(crate) async fn connect(
|
|||||||
.or_default() += 1;
|
.or_default() += 1;
|
||||||
}
|
}
|
||||||
let connect_future = tokio::net::TcpStream::connect((creds.host.as_str(), creds.port));
|
let connect_future = tokio::net::TcpStream::connect((creds.host.as_str(), creds.port));
|
||||||
// 10 second timeout for the initial TCP connection
|
|
||||||
let tcp = tokio::time::timeout(std::time::Duration::from_secs(10), connect_future)
|
let tcp = tokio::time::timeout(std::time::Duration::from_secs(10), connect_future)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ImapError::ConnectionFailed("connection timed out".to_string()))?
|
.map_err(|_| ImapError::ConnectionFailed("connection timed out".to_string()))?
|
||||||
@@ -68,10 +59,6 @@ pub(crate) async fn connect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable RFC 6855 UTF8=ACCEPT once per freshly authenticated session when
|
|
||||||
/// the server explicitly advertises both ENABLE and UTF8=ACCEPT. A tagged
|
|
||||||
/// NO/BAD is treated as a capability mismatch and leaves the session usable
|
|
||||||
/// in legacy mode; transport loss still invalidates the connection.
|
|
||||||
async fn maybe_enable_utf8_accept(
|
async fn maybe_enable_utf8_accept(
|
||||||
session: &mut async_imap::Session<ImapStream>,
|
session: &mut async_imap::Session<ImapStream>,
|
||||||
) -> Result<(), ImapError> {
|
) -> Result<(), ImapError> {
|
||||||
@@ -119,7 +106,6 @@ async fn maybe_enable_utf8_accept(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify an `async_imap::error::Error` that occurred during LOGIN.
|
|
||||||
pub(crate) fn classify_login_error(err: async_imap::error::Error) -> ImapError {
|
pub(crate) fn classify_login_error(err: async_imap::error::Error) -> ImapError {
|
||||||
match err {
|
match err {
|
||||||
async_imap::error::Error::No(_) => ImapError::AuthenticationFailed,
|
async_imap::error::Error::No(_) => ImapError::AuthenticationFailed,
|
||||||
@@ -131,7 +117,6 @@ pub(crate) fn classify_login_error(err: async_imap::error::Error) -> ImapError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a generic `async_imap` error to our `ImapError`.
|
|
||||||
pub(crate) fn map_imap_error(err: async_imap::error::Error) -> ImapError {
|
pub(crate) fn map_imap_error(err: async_imap::error::Error) -> ImapError {
|
||||||
match err {
|
match err {
|
||||||
async_imap::error::Error::No(msg) => ImapError::ProtocolError(format!("NO: {msg}")),
|
async_imap::error::Error::No(msg) => ImapError::ProtocolError(format!("NO: {msg}")),
|
||||||
@@ -143,10 +128,6 @@ pub(crate) fn map_imap_error(err: async_imap::error::Error) -> ImapError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Stream enum ----------------------------------------------------------
|
|
||||||
|
|
||||||
/// A wrapper enum so that `Session` can be generic over a single type
|
|
||||||
/// regardless of whether TLS is used.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) enum ImapStream {
|
pub(crate) enum ImapStream {
|
||||||
Tls(async_native_tls::TlsStream<tokio::net::TcpStream>),
|
Tls(async_native_tls::TlsStream<tokio::net::TcpStream>),
|
||||||
|
|||||||
+4
-6
@@ -1,17 +1,15 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// Errors that can occur during IMAP operations.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ImapError {
|
pub enum ImapError {
|
||||||
/// Could not connect to the IMAP server.
|
|
||||||
ConnectionFailed(String),
|
ConnectionFailed(String),
|
||||||
/// The server rejected our credentials.
|
|
||||||
AuthenticationFailed,
|
AuthenticationFailed,
|
||||||
/// The requested folder does not exist.
|
|
||||||
FolderNotFound(String),
|
FolderNotFound(String),
|
||||||
/// The requested message UID was not found in the given folder.
|
|
||||||
MessageNotFound { uid: u32, folder: String },
|
MessageNotFound { uid: u32, folder: String },
|
||||||
/// A low-level IMAP protocol error.
|
|
||||||
ProtocolError(String),
|
ProtocolError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-47
@@ -1,11 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// A mock IMAP client that returns pre-loaded data.
|
|
||||||
///
|
|
||||||
/// Uses interior mutability (`Mutex`) so it can be shared behind `&self`.
|
|
||||||
/// Build it up with the `.with_*()` builder methods, then pass it wherever
|
|
||||||
/// an `&dyn ImapClient` is needed.
|
|
||||||
pub struct MockImapClient {
|
pub struct MockImapClient {
|
||||||
folders: Mutex<Vec<ImapFolder>>,
|
folders: Mutex<Vec<ImapFolder>>,
|
||||||
headers: Mutex<Vec<ImapMessageHeader>>,
|
headers: Mutex<Vec<ImapMessageHeader>>,
|
||||||
@@ -15,12 +10,11 @@ pub struct MockImapClient {
|
|||||||
should_fail: Mutex<Option<ImapError>>,
|
should_fail: Mutex<Option<ImapError>>,
|
||||||
pub appended: Mutex<Vec<(String, Vec<u8>)>>,
|
pub appended: Mutex<Vec<(String, Vec<u8>)>>,
|
||||||
pub header_requests: Mutex<Vec<(String, String)>>,
|
pub header_requests: Mutex<Vec<(String, String)>>,
|
||||||
/// One-shot gate after taking a header snapshot, before returning it.
|
|
||||||
pub header_fetch_gate: Option<(tokio::sync::Notify, tokio::sync::Notify)>,
|
pub header_fetch_gate: Option<(tokio::sync::Notify, tokio::sync::Notify)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockImapClient {
|
impl MockImapClient {
|
||||||
/// Create a new empty mock.
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
folders: Mutex::new(Vec::new()),
|
folders: Mutex::new(Vec::new()),
|
||||||
@@ -35,47 +29,39 @@ impl MockImapClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-load folders that `list_folders` will return.
|
|
||||||
pub fn with_folders(self, folders: Vec<ImapFolder>) -> Self {
|
pub fn with_folders(self, folders: Vec<ImapFolder>) -> Self {
|
||||||
*self.folders.lock().unwrap() = folders;
|
*self.folders.lock().unwrap() = folders;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-load message headers that `fetch_headers` will return.
|
|
||||||
pub fn with_headers(self, headers: Vec<ImapMessageHeader>) -> Self {
|
pub fn with_headers(self, headers: Vec<ImapMessageHeader>) -> Self {
|
||||||
*self.headers.lock().unwrap() = headers;
|
*self.headers.lock().unwrap() = headers;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-load a folder status that `folder_status` will return.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn with_folder_status(self, status: FolderStatus) -> Self {
|
pub fn with_folder_status(self, status: FolderStatus) -> Self {
|
||||||
*self.folder_status.lock().unwrap() = Some(status);
|
*self.folder_status.lock().unwrap() = Some(status);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-load an extended folder status that `folder_status_extended` will return.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn with_folder_status_extended(self, status: FolderStatusExtended) -> Self {
|
pub fn with_folder_status_extended(self, status: FolderStatusExtended) -> Self {
|
||||||
*self.folder_status_ext.lock().unwrap() = Some(status);
|
*self.folder_status_ext.lock().unwrap() = Some(status);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-load message bodies that `fetch_body` will match against by UID.
|
|
||||||
pub fn with_bodies(self, bodies: Vec<ImapMessageBody>) -> Self {
|
pub fn with_bodies(self, bodies: Vec<ImapMessageBody>) -> Self {
|
||||||
*self.bodies.lock().unwrap() = bodies;
|
*self.bodies.lock().unwrap() = bodies;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make every subsequent call return this error.
|
|
||||||
pub fn with_error(self, error: ImapError) -> Self {
|
pub fn with_error(self, error: ImapError) -> Self {
|
||||||
*self.should_fail.lock().unwrap() = Some(error);
|
*self.should_fail.lock().unwrap() = Some(error);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to clone an `ImapError` for the mock (the real errors are not
|
|
||||||
/// `Clone`, so we reconstruct them).
|
|
||||||
fn clone_error(err: &ImapError) -> ImapError {
|
fn clone_error(err: &ImapError) -> ImapError {
|
||||||
match err {
|
match err {
|
||||||
ImapError::ConnectionFailed(msg) => ImapError::ConnectionFailed(msg.clone()),
|
ImapError::ConnectionFailed(msg) => ImapError::ConnectionFailed(msg.clone()),
|
||||||
@@ -127,7 +113,7 @@ impl ImapClient for MockImapClient {
|
|||||||
return Ok(s.clone());
|
return Ok(s.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Derive from headers (separate lock scope).
|
|
||||||
let headers = self.headers.lock().unwrap();
|
let headers = self.headers.lock().unwrap();
|
||||||
let exists = headers.len() as u32;
|
let exists = headers.len() as u32;
|
||||||
let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1;
|
let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1;
|
||||||
@@ -255,11 +241,7 @@ impl ImapClient for MockImapClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn empty_folder(
|
async fn empty_folder(&self, _creds: &ImapCredentials, _folder: &str) -> Result<(), ImapError> {
|
||||||
&self,
|
|
||||||
_creds: &ImapCredentials,
|
|
||||||
_folder: &str,
|
|
||||||
) -> Result<(), ImapError> {
|
|
||||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||||
return Err(clone_error(err));
|
return Err(clone_error(err));
|
||||||
}
|
}
|
||||||
@@ -390,7 +372,7 @@ impl ImapClient for MockImapClient {
|
|||||||
return Ok(s.clone());
|
return Ok(s.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Derive from headers.
|
|
||||||
let headers = self.headers.lock().unwrap();
|
let headers = self.headers.lock().unwrap();
|
||||||
let exists = headers.len() as u32;
|
let exists = headers.len() as u32;
|
||||||
let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1;
|
let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1;
|
||||||
@@ -412,7 +394,7 @@ impl ImapClient for MockImapClient {
|
|||||||
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
if let Some(ref err) = *self.should_fail.lock().unwrap() {
|
||||||
return Err(clone_error(err));
|
return Err(clone_error(err));
|
||||||
}
|
}
|
||||||
// In mock, return all headers as "changed" with modseq 0.
|
|
||||||
let headers = self.headers.lock().unwrap();
|
let headers = self.headers.lock().unwrap();
|
||||||
let items: Vec<(u32, Vec<String>)> =
|
let items: Vec<(u32, Vec<String>)> =
|
||||||
headers.iter().map(|h| (h.uid, h.flags.clone())).collect();
|
headers.iter().map(|h| (h.uid, h.flags.clone())).collect();
|
||||||
@@ -438,15 +420,10 @@ impl ImapClient for MockImapClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Convenience helper to build dummy credentials for tests.
|
|
||||||
fn test_creds() -> ImapCredentials {
|
fn test_creds() -> ImapCredentials {
|
||||||
ImapCredentials {
|
ImapCredentials {
|
||||||
host: "imap.example.com".to_string(),
|
host: "imap.example.com".to_string(),
|
||||||
@@ -636,7 +613,7 @@ mod tests {
|
|||||||
let creds = test_creds();
|
let creds = test_creds();
|
||||||
|
|
||||||
let err = client.list_folders(&creds).await.unwrap_err();
|
let err = client.list_folders(&creds).await.unwrap_err();
|
||||||
// With a fake host the connection should fail.
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, ImapError::ConnectionFailed(_)),
|
matches!(err, ImapError::ConnectionFailed(_)),
|
||||||
"Expected ConnectionFailed, got: {err}"
|
"Expected ConnectionFailed, got: {err}"
|
||||||
@@ -760,19 +737,6 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// Integration tests (run manually against a real IMAP server)
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// cargo test real_imap -- --ignored
|
|
||||||
//
|
|
||||||
// Required env vars:
|
|
||||||
// TEST_IMAP_HOST (e.g. "imap.gmail.com")
|
|
||||||
// TEST_IMAP_PORT (e.g. "993")
|
|
||||||
// TEST_IMAP_EMAIL (e.g. "you@gmail.com")
|
|
||||||
// TEST_IMAP_PASSWORD (e.g. "app-password")
|
|
||||||
// TEST_IMAP_TLS (e.g. "true")
|
|
||||||
|
|
||||||
fn real_creds() -> Option<ImapCredentials> {
|
fn real_creds() -> Option<ImapCredentials> {
|
||||||
let host = std::env::var("TEST_IMAP_HOST").ok()?;
|
let host = std::env::var("TEST_IMAP_HOST").ok()?;
|
||||||
let port: u16 = std::env::var("TEST_IMAP_PORT").ok()?.parse().ok()?;
|
let port: u16 = std::env::var("TEST_IMAP_PORT").ok()?.parse().ok()?;
|
||||||
@@ -793,7 +757,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore] // Run manually: cargo test real_imap_list_folders -- --ignored
|
#[ignore]
|
||||||
async fn real_imap_list_folders() {
|
async fn real_imap_list_folders() {
|
||||||
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
||||||
let client = RealImapClient::default();
|
let client = RealImapClient::default();
|
||||||
@@ -807,24 +771,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore] // Run manually: cargo test real_imap_fetch_headers -- --ignored
|
#[ignore]
|
||||||
async fn real_imap_fetch_headers() {
|
async fn real_imap_fetch_headers() {
|
||||||
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
||||||
let client = RealImapClient::default();
|
let client = RealImapClient::default();
|
||||||
let headers = client.fetch_headers(&creds, "INBOX", "1:5").await.unwrap();
|
let headers = client.fetch_headers(&creds, "INBOX", "1:5").await.unwrap();
|
||||||
// The mailbox might be empty, so we just check it doesn't error.
|
|
||||||
for h in &headers {
|
for h in &headers {
|
||||||
assert!(h.uid > 0);
|
assert!(h.uid > 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore] // Run manually: cargo test real_imap_fetch_body -- --ignored
|
#[ignore]
|
||||||
async fn real_imap_fetch_body() {
|
async fn real_imap_fetch_body() {
|
||||||
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
||||||
let client = RealImapClient::default();
|
let client = RealImapClient::default();
|
||||||
|
|
||||||
// First fetch headers to find a UID.
|
|
||||||
let headers = client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap();
|
let headers = client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap();
|
||||||
if let Some(h) = headers.first() {
|
if let Some(h) = headers.first() {
|
||||||
let body = client.fetch_body(&creds, "INBOX", h.uid).await.unwrap();
|
let body = client.fetch_body(&creds, "INBOX", h.uid).await.unwrap();
|
||||||
|
|||||||
+7
-25
@@ -1,7 +1,5 @@
|
|||||||
use super::types::EmailAddress;
|
use super::types::EmailAddress;
|
||||||
|
|
||||||
// ---- Helper: convert NameAttribute to string ------------------------------
|
|
||||||
|
|
||||||
pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'_>) -> String {
|
pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'_>) -> String {
|
||||||
use async_imap::types::NameAttribute;
|
use async_imap::types::NameAttribute;
|
||||||
match attr {
|
match attr {
|
||||||
@@ -21,8 +19,6 @@ pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helper: convert Flag to string ---------------------------------------
|
|
||||||
|
|
||||||
pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String {
|
pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String {
|
||||||
use async_imap::types::Flag;
|
use async_imap::types::Flag;
|
||||||
match flag {
|
match flag {
|
||||||
@@ -37,11 +33,7 @@ pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helper: decode RFC 2047 encoded words --------------------------------
|
|
||||||
|
|
||||||
/// Decode RFC 2047 encoded-word sequences like `=?utf-8?b?SGVsbG8=?=`.
|
|
||||||
pub(crate) fn decode_rfc2047(input: &str) -> String {
|
pub(crate) fn decode_rfc2047(input: &str) -> String {
|
||||||
// Quick check: if there's no encoded-word marker, return as-is.
|
|
||||||
if !input.contains("=?") {
|
if !input.contains("=?") {
|
||||||
return input.to_string();
|
return input.to_string();
|
||||||
}
|
}
|
||||||
@@ -50,11 +42,10 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
|
|||||||
let mut remaining = input;
|
let mut remaining = input;
|
||||||
|
|
||||||
while let Some(start) = remaining.find("=?") {
|
while let Some(start) = remaining.find("=?") {
|
||||||
// Add any text before the encoded word.
|
|
||||||
result.push_str(&remaining[..start]);
|
result.push_str(&remaining[..start]);
|
||||||
|
|
||||||
let after_start = &remaining[start + 2..];
|
let after_start = &remaining[start + 2..];
|
||||||
// Find charset?encoding?text?= pattern.
|
|
||||||
if let Some(q1) = after_start.find('?') {
|
if let Some(q1) = after_start.find('?') {
|
||||||
let charset = &after_start[..q1];
|
let charset = &after_start[..q1];
|
||||||
let after_charset = &after_start[q1 + 1..];
|
let after_charset = &after_start[q1 + 1..];
|
||||||
@@ -77,15 +68,13 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
|
|||||||
let text = decode_charset(charset, &bytes);
|
let text = decode_charset(charset, &bytes);
|
||||||
result.push_str(&text);
|
result.push_str(&text);
|
||||||
} else {
|
} else {
|
||||||
// Failed to decode — keep the original encoded word.
|
|
||||||
let full_len = 2 + q1 + 1 + q2 + 1 + end + 2;
|
let full_len = 2 + q1 + 1 + q2 + 1 + end + 2;
|
||||||
result.push_str(&remaining[start..start + full_len]);
|
result.push_str(&remaining[start..start + full_len]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip past the encoded word.
|
|
||||||
let skip = q1 + 1 + q2 + 1 + end + 2;
|
let skip = q1 + 1 + q2 + 1 + end + 2;
|
||||||
remaining = &after_start[skip..];
|
remaining = &after_start[skip..];
|
||||||
// Strip whitespace between consecutive encoded words (RFC 2047 §6.2).
|
|
||||||
if remaining.starts_with("=?") || remaining.trim_start().starts_with("=?") {
|
if remaining.starts_with("=?") || remaining.trim_start().starts_with("=?") {
|
||||||
remaining = remaining.trim_start();
|
remaining = remaining.trim_start();
|
||||||
}
|
}
|
||||||
@@ -94,7 +83,6 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Malformed encoded word — keep the `=?` and move past it.
|
|
||||||
result.push_str("=?");
|
result.push_str("=?");
|
||||||
remaining = after_start;
|
remaining = after_start;
|
||||||
}
|
}
|
||||||
@@ -103,7 +91,6 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode quoted-printable as used in RFC 2047 headers (underscores = spaces).
|
|
||||||
pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> {
|
pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> {
|
||||||
let mut bytes = Vec::with_capacity(input.len());
|
let mut bytes = Vec::with_capacity(input.len());
|
||||||
let mut chars = input.bytes();
|
let mut chars = input.bytes();
|
||||||
@@ -124,20 +111,19 @@ pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> {
|
|||||||
Some(bytes)
|
Some(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort charset decoding. UTF-8 and Latin-1 are the most common.
|
|
||||||
pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String {
|
pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String {
|
||||||
match charset.to_ascii_lowercase().as_str() {
|
match charset.to_ascii_lowercase().as_str() {
|
||||||
"utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(),
|
"utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(),
|
||||||
"iso-8859-1" | "latin1" | "latin-1" => bytes.iter().map(|&b| b as char).collect(),
|
"iso-8859-1" | "latin1" | "latin-1" => bytes.iter().map(|&b| b as char).collect(),
|
||||||
// For other charsets, try UTF-8 first, then fall back to Latin-1.
|
|
||||||
_ => String::from_utf8(bytes.to_vec())
|
_ => String::from_utf8(bytes.to_vec())
|
||||||
.unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect()),
|
.unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helper: convert imap_proto::Address to EmailAddress ------------------
|
pub(crate) fn imap_address_to_email(
|
||||||
|
addr: &async_imap::imap_proto::types::Address<'_>,
|
||||||
pub(crate) fn imap_address_to_email(addr: &async_imap::imap_proto::types::Address<'_>) -> EmailAddress {
|
) -> EmailAddress {
|
||||||
let name = addr
|
let name = addr
|
||||||
.name
|
.name
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -164,24 +150,20 @@ pub(crate) fn imap_address_to_email(addr: &async_imap::imap_proto::types::Addres
|
|||||||
EmailAddress { name, address }
|
EmailAddress { name, address }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helper: detect attachments from BODYSTRUCTURE -----------------------
|
|
||||||
|
|
||||||
pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<'_>) -> bool {
|
pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<'_>) -> bool {
|
||||||
use async_imap::imap_proto::types::BodyStructure;
|
use async_imap::imap_proto::types::BodyStructure;
|
||||||
match bs {
|
match bs {
|
||||||
BodyStructure::Basic { common, .. } => {
|
BodyStructure::Basic { common, .. } => {
|
||||||
// Check for explicit "attachment" disposition.
|
|
||||||
if let Some(ref disp) = common.disposition
|
if let Some(ref disp) = common.disposition
|
||||||
&& disp.ty.eq_ignore_ascii_case("attachment")
|
&& disp.ty.eq_ignore_ascii_case("attachment")
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Non-text basic parts (image, application, audio, video) are likely attachments.
|
|
||||||
let ty = common.ty.ty.to_ascii_lowercase();
|
let ty = common.ty.ty.to_ascii_lowercase();
|
||||||
matches!(ty.as_str(), "application" | "image" | "audio" | "video")
|
matches!(ty.as_str(), "application" | "image" | "audio" | "video")
|
||||||
}
|
}
|
||||||
BodyStructure::Text { common, .. } => {
|
BodyStructure::Text { common, .. } => {
|
||||||
// Text parts with "attachment" disposition count.
|
|
||||||
if let Some(ref disp) = common.disposition {
|
if let Some(ref disp) = common.disposition {
|
||||||
return disp.ty.eq_ignore_ascii_case("attachment");
|
return disp.ty.eq_ignore_ascii_case("attachment");
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-15
@@ -1,4 +1,3 @@
|
|||||||
//! Request-path sessions only; IDLE owns a separate connection.
|
|
||||||
use super::{
|
use super::{
|
||||||
connection::{ImapStream, connect},
|
connection::{ImapStream, connect},
|
||||||
error::ImapError,
|
error::ImapError,
|
||||||
@@ -19,7 +18,6 @@ use tokio::{
|
|||||||
time::Instant,
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Mark IMAP operations in this future as background work.
|
|
||||||
pub(crate) async fn background<T>(work: impl std::future::Future<Output = T>) -> T {
|
pub(crate) async fn background<T>(work: impl std::future::Future<Output = T>) -> T {
|
||||||
BACKGROUND.scope((), work).await
|
BACKGROUND.scope((), work).await
|
||||||
}
|
}
|
||||||
@@ -47,7 +45,6 @@ struct UserPool {
|
|||||||
idle: Mutex<Vec<(Instant, Session)>>,
|
idle: Mutex<Vec<(Instant, Session)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Length-prefix fields to avoid ambiguous identities. Never retain plaintext passwords.
|
|
||||||
pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] {
|
pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] {
|
||||||
let mut hash = Sha256::new();
|
let mut hash = Sha256::new();
|
||||||
for field in [
|
for field in [
|
||||||
@@ -64,8 +61,6 @@ pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] {
|
|||||||
hash.finalize().into()
|
hash.finalize().into()
|
||||||
}
|
}
|
||||||
|
|
||||||
// async-imap 0.10's noop parser accepts EOF without a tagged completion.
|
|
||||||
// Require the matching tagged OK ourselves so dead sockets cannot pass validation.
|
|
||||||
async fn validate(session: &mut Session) -> Result<(), ImapError> {
|
async fn validate(session: &mut Session) -> Result<(), ImapError> {
|
||||||
use async_imap::imap_proto::{Response, Status};
|
use async_imap::imap_proto::{Response, Status};
|
||||||
let id = session
|
let id = session
|
||||||
@@ -98,7 +93,7 @@ impl PoolInner {
|
|||||||
fn evict(&self) {
|
fn evict(&self) {
|
||||||
self.users.lock().unwrap().retain(|_, user| {
|
self.users.lock().unwrap().retain(|_, user| {
|
||||||
let mut idle = user.idle.lock().unwrap();
|
let mut idle = user.idle.lock().unwrap();
|
||||||
// Dropping a session closes its socket; never CLOSE (which expunges).
|
|
||||||
idle.retain(|(returned, _)| returned.elapsed() < IDLE_TTL);
|
idle.retain(|(returned, _)| returned.elapsed() < IDLE_TTL);
|
||||||
!idle.is_empty() || Arc::strong_count(user) > 1
|
!idle.is_empty() || Arc::strong_count(user) > 1
|
||||||
});
|
});
|
||||||
@@ -106,8 +101,6 @@ impl PoolInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectionPool {
|
impl ConnectionPool {
|
||||||
/// Drop all reusable request-path IMAP sessions for one account. Active
|
|
||||||
/// leases are detached from the pool and will be closed when returned.
|
|
||||||
pub(crate) fn clear_user(&self, creds: &ImapCredentials) {
|
pub(crate) fn clear_user(&self, creds: &ImapCredentials) {
|
||||||
self.inner.users.lock().unwrap().remove(&identity(creds));
|
self.inner.users.lock().unwrap().remove(&identity(creds));
|
||||||
}
|
}
|
||||||
@@ -139,7 +132,7 @@ impl ConnectionPool {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.clone();
|
.clone();
|
||||||
// Wait outside the shared budget so queued sync work cannot starve reads.
|
|
||||||
let background_permit = if BACKGROUND.try_with(|_| ()).is_ok() {
|
let background_permit = if BACKGROUND.try_with(|_| ()).is_ok() {
|
||||||
Some(
|
Some(
|
||||||
user.background_slots
|
user.background_slots
|
||||||
@@ -189,7 +182,7 @@ impl ConnectionPool {
|
|||||||
action = "connect",
|
action = "connect",
|
||||||
"IMAP pool checkout"
|
"IMAP pool checkout"
|
||||||
);
|
);
|
||||||
// Bound TCP, TLS and LOGIN together, retaining connect's TCP timeout too.
|
|
||||||
let session = tokio::time::timeout(CHECK_TIMEOUT, connect(creds))
|
let session = tokio::time::timeout(CHECK_TIMEOUT, connect(creds))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ImapError::ConnectionFailed("connection timed out".into()))??;
|
.map_err(|_| ImapError::ConnectionFailed("connection timed out".into()))??;
|
||||||
@@ -202,8 +195,6 @@ impl ConnectionPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Defaults to discard, including when a request is cancelled mid-command.
|
|
||||||
/// Only explicitly completed operations may return the session for reuse.
|
|
||||||
pub(crate) struct Lease {
|
pub(crate) struct Lease {
|
||||||
session: Option<Session>,
|
session: Option<Session>,
|
||||||
user: Arc<UserPool>,
|
user: Arc<UserPool>,
|
||||||
@@ -299,7 +290,6 @@ mod tests {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if line.contains(" UID FETCH ") && selected == "stalled" {
|
if line.contains(" UID FETCH ") && selected == "stalled" {
|
||||||
// No completion. The caller must discard on cancellation/timeout.
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let response = if line.contains(" GETQUOTAROOT ") {
|
let response = if line.contains(" GETQUOTAROOT ") {
|
||||||
@@ -402,7 +392,7 @@ mod tests {
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS);
|
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS);
|
||||||
drop(leases.pop()); // Unfinished work must not be reused.
|
drop(leases.pop());
|
||||||
let lease = pool.checkout(&creds).await.unwrap();
|
let lease = pool.checkout(&creds).await.unwrap();
|
||||||
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1);
|
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1);
|
||||||
lease.recycle();
|
lease.recycle();
|
||||||
@@ -478,7 +468,7 @@ mod tests {
|
|||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
client.list_folders(&creds).await.unwrap();
|
client.list_folders(&creds).await.unwrap();
|
||||||
assert!(client.get_quota(&creds).await.unwrap().is_none());
|
assert!(client.get_quota(&creds).await.unwrap().is_none());
|
||||||
// A server without a configured quota triggers a size read per folder.
|
|
||||||
assert_eq!(client.fetch_folder_size(&creds, "INBOX").await.unwrap(), 0);
|
assert_eq!(client.fetch_folder_size(&creds, "INBOX").await.unwrap(), 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.fetch_folder_size(&creds, "Archive").await.unwrap(),
|
client.fetch_folder_size(&creds, "Archive").await.unwrap(),
|
||||||
|
|||||||
+26
-44
@@ -1,88 +1,76 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Represents an IMAP folder (mailbox).
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ImapFolder {
|
pub struct ImapFolder {
|
||||||
/// Folder name as returned by the IMAP server (e.g. "INBOX", "Sent").
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Delimiter used by the server (e.g. "/" or ".").
|
|
||||||
pub delimiter: Option<String>,
|
pub delimiter: Option<String>,
|
||||||
/// IMAP attributes for this folder (e.g. `\Noselect`, `\HasChildren`).
|
|
||||||
pub attributes: Vec<String>,
|
pub attributes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A parsed email address with optional display name.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct EmailAddress {
|
pub struct EmailAddress {
|
||||||
/// Display name, if present (e.g. "Alice Smith").
|
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
/// The actual email address (e.g. "alice@example.com").
|
|
||||||
pub address: String,
|
pub address: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A lightweight summary of an email message (envelope data).
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ImapMessageHeader {
|
pub struct ImapMessageHeader {
|
||||||
/// IMAP UID of the message within its folder.
|
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
/// Subject line.
|
|
||||||
pub subject: Option<String>,
|
pub subject: Option<String>,
|
||||||
/// Sender(s) of the message.
|
|
||||||
pub from: Vec<EmailAddress>,
|
pub from: Vec<EmailAddress>,
|
||||||
/// Recipient(s) of the message.
|
|
||||||
pub to: Vec<EmailAddress>,
|
pub to: Vec<EmailAddress>,
|
||||||
/// Date header value (raw string from the server).
|
|
||||||
pub date: Option<String>,
|
pub date: Option<String>,
|
||||||
/// IMAP flags currently set on this message (e.g. `\Seen`, `\Flagged`).
|
|
||||||
pub flags: Vec<String>,
|
pub flags: Vec<String>,
|
||||||
/// Whether this message has attachments (derived from BODYSTRUCTURE).
|
|
||||||
pub has_attachments: bool,
|
pub has_attachments: bool,
|
||||||
/// RFC 2822 size of the message in bytes.
|
|
||||||
pub size: u32,
|
pub size: u32,
|
||||||
/// Message-ID header value for threading.
|
|
||||||
pub message_id: Option<String>,
|
pub message_id: Option<String>,
|
||||||
/// In-Reply-To header value for threading.
|
|
||||||
pub in_reply_to: Option<String>,
|
pub in_reply_to: Option<String>,
|
||||||
/// References header value for threading.
|
|
||||||
pub references: Option<String>,
|
pub references: Option<String>,
|
||||||
/// CC addresses.
|
|
||||||
pub cc: Vec<EmailAddress>,
|
pub cc: Vec<EmailAddress>,
|
||||||
/// Reaction emoji if this is an Outlook/Exchange reaction notification.
|
|
||||||
pub reaction: Option<String>,
|
pub reaction: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The full body of an email message, including attachments.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ImapMessageBody {
|
pub struct ImapMessageBody {
|
||||||
/// IMAP UID of the message within its folder.
|
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
/// Plain-text body part, if available.
|
|
||||||
pub text_plain: Option<String>,
|
pub text_plain: Option<String>,
|
||||||
/// HTML body part, if available.
|
|
||||||
pub text_html: Option<String>,
|
pub text_html: Option<String>,
|
||||||
/// List of attachments found in the message.
|
|
||||||
pub attachments: Vec<ImapAttachment>,
|
pub attachments: Vec<ImapAttachment>,
|
||||||
/// Raw RFC 822 headers as a single string.
|
|
||||||
pub raw_headers: String,
|
pub raw_headers: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metadata about a single attachment in an email message.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ImapAttachment {
|
pub struct ImapAttachment {
|
||||||
/// Filename of the attachment, if provided by the sender.
|
|
||||||
pub filename: Option<String>,
|
pub filename: Option<String>,
|
||||||
/// MIME content type (e.g. "application/pdf").
|
|
||||||
pub content_type: String,
|
pub content_type: String,
|
||||||
/// Size in bytes.
|
|
||||||
pub size: usize,
|
pub size: usize,
|
||||||
/// Raw attachment content.
|
|
||||||
pub data: Vec<u8>,
|
pub data: Vec<u8>,
|
||||||
/// Content-ID for inline images (e.g. "image001@01D1234"), without angle brackets.
|
|
||||||
pub content_id: Option<String>,
|
pub content_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters needed to establish an IMAP connection.
|
|
||||||
/// Passed explicitly to every trait method so the trait stays stateless.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ImapCredentials {
|
pub struct ImapCredentials {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -93,28 +81,22 @@ pub struct ImapCredentials {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lightweight result of an IMAP `SELECT` command.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct FolderStatus {
|
pub struct FolderStatus {
|
||||||
/// UIDVALIDITY - changes when the mailbox is rebuilt or UIDs are reassigned.
|
|
||||||
pub uid_validity: u32,
|
pub uid_validity: u32,
|
||||||
/// The total number of messages currently in the folder.
|
|
||||||
pub exists: u32,
|
pub exists: u32,
|
||||||
/// The highest UID that will be assigned to the next appended message.
|
|
||||||
pub uid_next: u32,
|
pub uid_next: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mailbox quota information from IMAP GETQUOTAROOT.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct MailboxQuota {
|
pub struct MailboxQuota {
|
||||||
/// Storage used in bytes (STORAGE resource reports in KB, we convert).
|
|
||||||
pub usage_bytes: u64,
|
pub usage_bytes: u64,
|
||||||
/// Storage limit in bytes.
|
|
||||||
pub limit_bytes: u64,
|
pub limit_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extended folder status from an IMAP `STATUS` command with CONDSTORE fields.
|
|
||||||
/// Used for cheap pre-checks before full sync.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct FolderStatusExtended {
|
pub struct FolderStatusExtended {
|
||||||
pub uid_validity: u32,
|
pub uid_validity: u32,
|
||||||
|
|||||||
-10
@@ -32,14 +32,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize structured JSON logging with env filter.
|
|
||||||
// Default to INFO level; override with RUST_LOG env var.
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||||
.with(fmt::layer().json())
|
.with(fmt::layer().json())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
// Load configuration via figment (serde defaults + env vars).
|
|
||||||
let loaded_config = AppConfig::load()?;
|
let loaded_config = AppConfig::load()?;
|
||||||
loaded_config.validate().map_err(|message| {
|
loaded_config.validate().map_err(|message| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
@@ -53,13 +50,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
let config = Arc::new(loaded_config);
|
let config = Arc::new(loaded_config);
|
||||||
|
|
||||||
// Create the in-memory session store with the configured timeout.
|
|
||||||
let store = Arc::new(SessionStore::new_with_tls_verify(
|
let store = Arc::new(SessionStore::new_with_tls_verify(
|
||||||
Duration::from_secs(config.session_timeout_hours * 3600),
|
Duration::from_secs(config.session_timeout_hours * 3600),
|
||||||
config.tls_verify,
|
config.tls_verify,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Spawn a background task that periodically purges expired sessions.
|
|
||||||
{
|
{
|
||||||
let store = Arc::clone(&store);
|
let store = Arc::clone(&store);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -72,20 +67,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the IMAP and SMTP clients for production use.
|
|
||||||
let imap_client: Arc<dyn imap::client::ImapClient> = Arc::new(RealImapClient::default());
|
let imap_client: Arc<dyn imap::client::ImapClient> = Arc::new(RealImapClient::default());
|
||||||
let smtp_client: Arc<dyn smtp::client::SmtpClient> = Arc::new(RealSmtpClient);
|
let smtp_client: Arc<dyn smtp::client::SmtpClient> = Arc::new(RealSmtpClient);
|
||||||
|
|
||||||
// Create the Tantivy search engine for full-text indexing.
|
|
||||||
let search_engine = Arc::new(search::engine::SearchEngine::new(std::path::PathBuf::from(
|
let search_engine = Arc::new(search::engine::SearchEngine::new(std::path::PathBuf::from(
|
||||||
&config.data_dir,
|
&config.data_dir,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// Create the real-time event bus and IDLE manager.
|
|
||||||
let event_bus = Arc::new(realtime::events::EventBus::new());
|
let event_bus = Arc::new(realtime::events::EventBus::new());
|
||||||
let idle_manager = Arc::new(realtime::idle::IdleManager::new());
|
let idle_manager = Arc::new(realtime::idle::IdleManager::new());
|
||||||
|
|
||||||
// Build the application router with auth, session, and static file serving.
|
|
||||||
let app = routes::create_router(
|
let app = routes::create_router(
|
||||||
config.clone(),
|
config.clone(),
|
||||||
store,
|
store,
|
||||||
@@ -96,7 +87,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
idle_manager.clone(),
|
idle_manager.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bind to the configured host and port.
|
|
||||||
let bind_addr = format!("{}:{}", config.host, config.port);
|
let bind_addr = format!("{}:{}", config.host, config.port);
|
||||||
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
|
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ async fn https_get_address(
|
|||||||
format!("{host}:{port}")
|
format!("{host}:{port}")
|
||||||
};
|
};
|
||||||
let request = format!(
|
let request = format!(
|
||||||
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.4\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n"
|
"GET {target} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.5\r\nAccept: application/octet-stream, application/pgp-keys, text/plain\r\nConnection: close\r\n\r\n"
|
||||||
);
|
);
|
||||||
timeout(duration, stream.write_all(request.as_bytes()))
|
timeout(duration, stream.write_all(request.as_bytes()))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -78,8 +78,6 @@ pub fn generate_key(name: &str, email: &str, passphrase: &str) -> Result<Importe
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn import_key(data: &[u8]) -> Result<ImportedKey, String> {
|
pub fn import_key(data: &[u8]) -> Result<ImportedKey, String> {
|
||||||
// rPGP exposes separate helpers for ASCII armor and binary packet streams.
|
|
||||||
// Accept both so keys exported by GnuPG, Thunderbird and WKD/HKP all work.
|
|
||||||
let armored = data.starts_with(b"-----BEGIN PGP");
|
let armored = data.starts_with(b"-----BEGIN PGP");
|
||||||
if armored {
|
if armored {
|
||||||
let text = std::str::from_utf8(data)
|
let text = std::str::from_utf8(data)
|
||||||
@@ -171,9 +169,6 @@ pub fn sign_detached(data: &[u8], secret_armor: &str, passphrase: &str) -> Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn test_secret_key_passphrase(secret_armor: &str, passphrase: &str) -> Result<(), String> {
|
pub fn test_secret_key_passphrase(secret_armor: &str, passphrase: &str) -> Result<(), String> {
|
||||||
// The key is never modified and the passphrase is not persisted. A short
|
|
||||||
// detached signature forces rPGP to unlock the private signing material.
|
|
||||||
// RustBox-generated keys always have a signing-capable primary key.
|
|
||||||
sign_detached(
|
sign_detached(
|
||||||
b"RustBox OpenPGP unlock self-test",
|
b"RustBox OpenPGP unlock self-test",
|
||||||
secret_armor,
|
secret_armor,
|
||||||
@@ -374,9 +369,6 @@ pub fn store_secret_key(
|
|||||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create PGP key directory: {e}"))?;
|
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create PGP key directory: {e}"))?;
|
||||||
set_dir_permissions(&dir)?;
|
set_dir_permissions(&dir)?;
|
||||||
|
|
||||||
// Reject path/control punctuation before normalization. `normalize_fingerprint`
|
|
||||||
// intentionally strips formatting characters, so validating only its output
|
|
||||||
// would turn values such as "../../escape" into a short hexadecimal string.
|
|
||||||
if fingerprint.is_empty()
|
if fingerprint.is_empty()
|
||||||
|| !fingerprint
|
|| !fingerprint
|
||||||
.chars()
|
.chars()
|
||||||
@@ -410,9 +402,6 @@ pub fn delete_secret_key(path: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Change the passphrase protecting an existing private key without exposing
|
|
||||||
/// passphrases in argv/environment. rPGP validates both the old and resulting
|
|
||||||
/// key; GnuPG is used only as a mature secret-packet re-wrapping engine.
|
|
||||||
pub fn change_secret_key_passphrase(
|
pub fn change_secret_key_passphrase(
|
||||||
data_dir: &str,
|
data_dir: &str,
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
@@ -680,10 +669,6 @@ pub fn looks_like_openpgp(raw: &[u8]) -> bool {
|
|||||||
|| lower.contains("-----begin pgp signed message-----")
|
|| lower.contains("-----begin pgp signed message-----")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process an OpenPGP/MIME or inline OpenPGP message. The caller supplies
|
|
||||||
/// private key armor candidates and the public keyring. Passphrases are never
|
|
||||||
/// persisted; one request may try the same supplied passphrase against every
|
|
||||||
/// candidate key.
|
|
||||||
pub fn process_message(
|
pub fn process_message(
|
||||||
raw: &[u8],
|
raw: &[u8],
|
||||||
secret_armors: &[String],
|
secret_armors: &[String],
|
||||||
|
|||||||
+8
-21
@@ -3,29 +3,25 @@ use std::collections::HashMap;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::sync::{RwLock, broadcast};
|
use tokio::sync::{RwLock, broadcast};
|
||||||
|
|
||||||
/// Events that can be pushed to connected WebSocket clients.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "type", content = "data")]
|
#[serde(tag = "type", content = "data")]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum MailEvent {
|
pub enum MailEvent {
|
||||||
/// New messages arrived in a folder.
|
|
||||||
NewMessages {
|
NewMessages {
|
||||||
folder: String,
|
folder: String,
|
||||||
count: u32,
|
count: u32,
|
||||||
|
latest_uid: Option<u32>,
|
||||||
latest_sender: Option<String>,
|
latest_sender: Option<String>,
|
||||||
latest_subject: Option<String>,
|
latest_subject: Option<String>,
|
||||||
},
|
},
|
||||||
/// Flags changed on messages in a folder.
|
|
||||||
FlagsChanged { folder: String },
|
FlagsChanged {
|
||||||
/// Folder list or counts changed.
|
folder: String,
|
||||||
|
},
|
||||||
|
|
||||||
FolderUpdated,
|
FolderUpdated,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fan-out event bus: per-user broadcast channels.
|
|
||||||
///
|
|
||||||
/// Each user (keyed by `user_hash`) gets a `broadcast::Sender` that can have
|
|
||||||
/// multiple receivers (one per WebSocket connection). When all receivers are
|
|
||||||
/// dropped the channel is cleaned up.
|
|
||||||
pub struct EventBus {
|
pub struct EventBus {
|
||||||
channels: RwLock<HashMap<String, broadcast::Sender<MailEvent>>>,
|
channels: RwLock<HashMap<String, broadcast::Sender<MailEvent>>>,
|
||||||
}
|
}
|
||||||
@@ -37,10 +33,7 @@ impl EventBus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to events for a user. Creates the channel if it doesn't exist.
|
|
||||||
/// Returns a receiver that will receive all future events for this user.
|
|
||||||
pub async fn subscribe(&self, user_hash: &str) -> broadcast::Receiver<MailEvent> {
|
pub async fn subscribe(&self, user_hash: &str) -> broadcast::Receiver<MailEvent> {
|
||||||
// Fast path: read lock.
|
|
||||||
{
|
{
|
||||||
let channels = self.channels.read().await;
|
let channels = self.channels.read().await;
|
||||||
if let Some(tx) = channels.get(user_hash) {
|
if let Some(tx) = channels.get(user_hash) {
|
||||||
@@ -48,7 +41,6 @@ impl EventBus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: write lock to insert.
|
|
||||||
let mut channels = self.channels.write().await;
|
let mut channels = self.channels.write().await;
|
||||||
let tx = channels.entry(user_hash.to_string()).or_insert_with(|| {
|
let tx = channels.entry(user_hash.to_string()).or_insert_with(|| {
|
||||||
let (tx, _) = broadcast::channel(64);
|
let (tx, _) = broadcast::channel(64);
|
||||||
@@ -57,22 +49,16 @@ impl EventBus {
|
|||||||
tx.subscribe()
|
tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish an event to all connected clients for a user.
|
|
||||||
/// No-op if no one is listening.
|
|
||||||
pub async fn publish(&self, user_hash: &str, event: MailEvent) {
|
pub async fn publish(&self, user_hash: &str, event: MailEvent) {
|
||||||
let channels = self.channels.read().await;
|
let channels = self.channels.read().await;
|
||||||
if let Some(tx) = channels.get(user_hash) {
|
if let Some(tx) = channels.get(user_hash) {
|
||||||
// Ignore error (no receivers connected).
|
|
||||||
let _ = tx.send(event);
|
let _ = tx.send(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a user's channel when no more connections exist.
|
|
||||||
/// Called when the last WebSocket for a user disconnects.
|
|
||||||
pub async fn cleanup(&self, user_hash: &str) {
|
pub async fn cleanup(&self, user_hash: &str) {
|
||||||
let mut channels = self.channels.write().await;
|
let mut channels = self.channels.write().await;
|
||||||
if let Some(tx) = channels.get(user_hash) {
|
if let Some(tx) = channels.get(user_hash) {
|
||||||
// Only remove if no active receivers remain.
|
|
||||||
if tx.receiver_count() == 0 {
|
if tx.receiver_count() == 0 {
|
||||||
channels.remove(user_hash);
|
channels.remove(user_hash);
|
||||||
}
|
}
|
||||||
@@ -101,6 +87,7 @@ mod tests {
|
|||||||
MailEvent::NewMessages {
|
MailEvent::NewMessages {
|
||||||
folder: "INBOX".to_string(),
|
folder: "INBOX".to_string(),
|
||||||
count: 1,
|
count: 1,
|
||||||
|
latest_uid: Some(42),
|
||||||
latest_sender: Some("alice@example.com".to_string()),
|
latest_sender: Some("alice@example.com".to_string()),
|
||||||
latest_subject: Some("Hello".to_string()),
|
latest_subject: Some("Hello".to_string()),
|
||||||
},
|
},
|
||||||
@@ -120,7 +107,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn publish_to_nonexistent_user_is_noop() {
|
async fn publish_to_nonexistent_user_is_noop() {
|
||||||
let bus = EventBus::new();
|
let bus = EventBus::new();
|
||||||
// Should not panic.
|
|
||||||
bus.publish("ghost", MailEvent::FolderUpdated).await;
|
bus.publish("ghost", MailEvent::FolderUpdated).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-80
@@ -11,8 +11,6 @@ use crate::imap::parse::{decode_rfc2047, flag_to_string, has_attachments, imap_a
|
|||||||
use crate::realtime::events::{EventBus, MailEvent};
|
use crate::realtime::events::{EventBus, MailEvent};
|
||||||
use crate::{db, imap};
|
use crate::{db, imap};
|
||||||
|
|
||||||
/// Manages long-lived IMAP IDLE connections, one per (user, folder) pair.
|
|
||||||
/// Each WebSocket holds a subscription; only the last disconnect stops IDLE.
|
|
||||||
pub struct IdleManager {
|
pub struct IdleManager {
|
||||||
tasks: DashMap<(String, String), IdleTask>,
|
tasks: DashMap<(String, String), IdleTask>,
|
||||||
}
|
}
|
||||||
@@ -29,7 +27,6 @@ impl IdleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to IDLE for a user + folder, sharing an existing task.
|
|
||||||
pub async fn start_idle(
|
pub async fn start_idle(
|
||||||
&self,
|
&self,
|
||||||
user_hash: String,
|
user_hash: String,
|
||||||
@@ -56,7 +53,6 @@ impl IdleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release this socket's subscription, without stopping other sockets.
|
|
||||||
pub async fn stop_idle(&self, user_hash: &str, folder: &str) {
|
pub async fn stop_idle(&self, user_hash: &str, folder: &str) {
|
||||||
use dashmap::mapref::entry::Entry;
|
use dashmap::mapref::entry::Entry;
|
||||||
|
|
||||||
@@ -70,7 +66,6 @@ impl IdleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Abort all dedicated IDLE tasks during process shutdown.
|
|
||||||
pub fn stop_all(&self) {
|
pub fn stop_all(&self) {
|
||||||
for task in self.tasks.iter() {
|
for task in self.tasks.iter() {
|
||||||
task.value().handle.abort();
|
task.value().handle.abort();
|
||||||
@@ -93,12 +88,6 @@ impl Default for IdleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The inner IDLE loop with auto-reconnect and exponential backoff.
|
|
||||||
///
|
|
||||||
/// This opens a dedicated IMAP connection, SELECTs the folder, and enters
|
|
||||||
/// IDLE mode. When the server notifies of changes, it publishes an event
|
|
||||||
/// to the EventBus and re-enters IDLE. If the connection drops, it
|
|
||||||
/// reconnects with exponential backoff.
|
|
||||||
async fn idle_loop(
|
async fn idle_loop(
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
@@ -112,7 +101,6 @@ async fn idle_loop(
|
|||||||
loop {
|
loop {
|
||||||
match run_idle_session(user_hash, folder, creds, event_bus, config).await {
|
match run_idle_session(user_hash, folder, creds, event_bus, config).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Session ended normally (shouldn't happen in practice).
|
|
||||||
tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session ended normally");
|
tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session ended normally");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -131,11 +119,6 @@ async fn idle_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a single IDLE session. Returns Err on connection/protocol errors.
|
|
||||||
///
|
|
||||||
/// The async-imap IDLE API follows an ownership pattern:
|
|
||||||
/// `Session` → `.idle()` consumes session → `Handle` → `.init()` sends IDLE
|
|
||||||
/// → `.wait()` listens → `.done()` sends DONE and returns `Session`.
|
|
||||||
async fn run_idle_session(
|
async fn run_idle_session(
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
@@ -156,31 +139,22 @@ async fn run_idle_session(
|
|||||||
|
|
||||||
tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session started");
|
tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session started");
|
||||||
|
|
||||||
// Re-enter IDLE every 25 minutes (RFC recommends max 29 min).
|
|
||||||
let idle_timeout = std::time::Duration::from_secs(25 * 60);
|
let idle_timeout = std::time::Duration::from_secs(25 * 60);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// `.idle()` consumes the session, wrapping it in a Handle.
|
|
||||||
let mut idle_handle = session.idle();
|
let mut idle_handle = session.idle();
|
||||||
|
|
||||||
// Initialize the IDLE command with the server.
|
|
||||||
idle_handle
|
idle_handle
|
||||||
.init()
|
.init()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("IDLE init failed: {e}"))?;
|
.map_err(|e| format!("IDLE init failed: {e}"))?;
|
||||||
|
|
||||||
// Start listening for server notifications.
|
|
||||||
// IMPORTANT: `interrupt` must be kept alive — dropping it triggers
|
|
||||||
// ManualInterrupt and ends the wait immediately.
|
|
||||||
let (idle_wait, interrupt) = idle_handle.wait();
|
let (idle_wait, interrupt) = idle_handle.wait();
|
||||||
|
|
||||||
// Wait for the server to send an unsolicited response, or timeout.
|
|
||||||
let result = tokio::time::timeout(idle_timeout, idle_wait).await;
|
let result = tokio::time::timeout(idle_timeout, idle_wait).await;
|
||||||
|
|
||||||
// Drop the interrupt handle now that we're done waiting.
|
|
||||||
drop(interrupt);
|
drop(interrupt);
|
||||||
|
|
||||||
// Send DONE to end IDLE and get the session back.
|
|
||||||
session = idle_handle
|
session = idle_handle
|
||||||
.done()
|
.done()
|
||||||
.await
|
.await
|
||||||
@@ -197,14 +171,10 @@ async fn run_idle_session(
|
|||||||
"IDLE: new data from server"
|
"IDLE: new data from server"
|
||||||
);
|
);
|
||||||
|
|
||||||
// CONDSTORE sync is fast (~200-500ms), so run it
|
|
||||||
// BEFORE publishing. This ensures the DB is up-to-date
|
|
||||||
// when the frontend refetches.
|
|
||||||
let result;
|
let result;
|
||||||
(session, result) =
|
(session, result) =
|
||||||
fetch_new_after_idle(session, user_hash, folder, config).await;
|
fetch_new_after_idle(session, user_hash, folder, config).await;
|
||||||
|
|
||||||
// Publish the correct event based on what changed.
|
|
||||||
if result.new_count > 0 {
|
if result.new_count > 0 {
|
||||||
event_bus
|
event_bus
|
||||||
.publish(
|
.publish(
|
||||||
@@ -212,6 +182,7 @@ async fn run_idle_session(
|
|||||||
MailEvent::NewMessages {
|
MailEvent::NewMessages {
|
||||||
folder: folder.to_string(),
|
folder: folder.to_string(),
|
||||||
count: result.new_count,
|
count: result.new_count,
|
||||||
|
latest_uid: result.latest_uid,
|
||||||
latest_sender: result.latest_sender,
|
latest_sender: result.latest_sender,
|
||||||
latest_subject: result.latest_subject,
|
latest_subject: result.latest_subject,
|
||||||
},
|
},
|
||||||
@@ -245,11 +216,9 @@ async fn run_idle_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
// IDLE protocol error.
|
|
||||||
return Err(format!("IDLE error: {e}"));
|
return Err(format!("IDLE error: {e}"));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Our timeout — re-enter IDLE to keep the connection alive.
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
user_hash = %user_hash,
|
user_hash = %user_hash,
|
||||||
folder = %folder,
|
folder = %folder,
|
||||||
@@ -260,11 +229,11 @@ async fn run_idle_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Summary of what changed during a post-IDLE reconciliation.
|
|
||||||
struct ReconcileResult {
|
struct ReconcileResult {
|
||||||
new_count: u32,
|
new_count: u32,
|
||||||
flags_updated: u32,
|
flags_updated: u32,
|
||||||
deleted_count: u32,
|
deleted_count: u32,
|
||||||
|
latest_uid: Option<u32>,
|
||||||
latest_sender: Option<String>,
|
latest_sender: Option<String>,
|
||||||
latest_subject: Option<String>,
|
latest_subject: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -275,26 +244,19 @@ impl ReconcileResult {
|
|||||||
new_count: 0,
|
new_count: 0,
|
||||||
flags_updated: 0,
|
flags_updated: 0,
|
||||||
deleted_count: 0,
|
deleted_count: 0,
|
||||||
|
latest_uid: None,
|
||||||
latest_sender: None,
|
latest_sender: None,
|
||||||
latest_subject: None,
|
latest_subject: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile the local cache after IDLE detects new data.
|
|
||||||
///
|
|
||||||
/// Uses CONDSTORE when available for fast incremental sync (~200-500ms),
|
|
||||||
/// falling back to a full UID scan when CONDSTORE is not supported.
|
|
||||||
///
|
|
||||||
/// If anything fails, it logs a warning and returns the session unchanged —
|
|
||||||
/// the periodic sync loop provides a safety net.
|
|
||||||
async fn fetch_new_after_idle(
|
async fn fetch_new_after_idle(
|
||||||
mut session: async_imap::Session<ImapStream>,
|
mut session: async_imap::Session<ImapStream>,
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
) -> (async_imap::Session<ImapStream>, ReconcileResult) {
|
) -> (async_imap::Session<ImapStream>, ReconcileResult) {
|
||||||
// Read cached folder state from DB.
|
|
||||||
let (cached_modseq, max_cached_uid, cached_count, cached_uid_validity, header_sync) =
|
let (cached_modseq, max_cached_uid, cached_count, cached_uid_validity, header_sync) =
|
||||||
match db::pool::open_user_db(&config.data_dir, user_hash) {
|
match db::pool::open_user_db(&config.data_dir, user_hash) {
|
||||||
Ok(conn) => {
|
Ok(conn) => {
|
||||||
@@ -318,7 +280,6 @@ async fn fetch_new_after_idle(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-SELECT with CONDSTORE to get fresh mailbox state.
|
|
||||||
let mailbox = match session.select_condstore(folder).await {
|
let mailbox = match session.select_condstore(folder).await {
|
||||||
Ok(mb) => mb,
|
Ok(mb) => mb,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -331,7 +292,6 @@ async fn fetch_new_after_idle(
|
|||||||
let server_modseq = mailbox.highest_modseq.unwrap_or(0);
|
let server_modseq = mailbox.highest_modseq.unwrap_or(0);
|
||||||
let uid_validity = mailbox.uid_validity.unwrap_or(0);
|
let uid_validity = mailbox.uid_validity.unwrap_or(0);
|
||||||
|
|
||||||
// Let list sync reset the namespace before IDLE writes any reused UIDs.
|
|
||||||
if cached_uid_validity != 0 && cached_uid_validity != uid_validity {
|
if cached_uid_validity != 0 && cached_uid_validity != uid_validity {
|
||||||
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) {
|
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) {
|
||||||
let _ = db::folders::invalidate_folder_freshness(&conn, folder);
|
let _ = db::folders::invalidate_folder_freshness(&conn, folder);
|
||||||
@@ -339,7 +299,6 @@ async fn fetch_new_after_idle(
|
|||||||
return (session, ReconcileResult::empty());
|
return (session, ReconcileResult::empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Early exit: nothing changed (CONDSTORE modseq and count both match).
|
|
||||||
if server_modseq > 0 && server_modseq == cached_modseq && server_exists == cached_count {
|
if server_modseq > 0 && server_modseq == cached_modseq && server_exists == cached_count {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
user_hash = %user_hash,
|
user_hash = %user_hash,
|
||||||
@@ -350,7 +309,6 @@ async fn fetch_new_after_idle(
|
|||||||
return (session, ReconcileResult::empty());
|
return (session, ReconcileResult::empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CONDSTORE fast path ──────────────────────────────────────────
|
|
||||||
if cached_modseq > 0 && server_modseq > 0 {
|
if cached_modseq > 0 && server_modseq > 0 {
|
||||||
let fetch_query = format!("(UID FLAGS) (CHANGEDSINCE {})", cached_modseq);
|
let fetch_query = format!("(UID FLAGS) (CHANGEDSINCE {})", cached_modseq);
|
||||||
let changed_items: Option<Vec<(u32, Vec<String>)>> =
|
let changed_items: Option<Vec<(u32, Vec<String>)>> =
|
||||||
@@ -388,7 +346,6 @@ async fn fetch_new_after_idle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(changed) = changed_items {
|
if let Some(changed) = changed_items {
|
||||||
// Separate flag changes (uid ≤ max) from new messages (uid > max).
|
|
||||||
let mut flag_changes: Vec<(u32, Vec<String>)> = Vec::new();
|
let mut flag_changes: Vec<(u32, Vec<String>)> = Vec::new();
|
||||||
let mut has_new = false;
|
let mut has_new = false;
|
||||||
for (uid, flags) in changed {
|
for (uid, flags) in changed {
|
||||||
@@ -399,7 +356,6 @@ async fn fetch_new_after_idle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply flag changes to DB.
|
|
||||||
let mut flags_updated = 0u32;
|
let mut flags_updated = 0u32;
|
||||||
if !flag_changes.is_empty()
|
if !flag_changes.is_empty()
|
||||||
&& let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
&& let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||||
@@ -424,7 +380,6 @@ async fn fetch_new_after_idle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch full headers for new messages.
|
|
||||||
let mut new_count = 0u32;
|
let mut new_count = 0u32;
|
||||||
if has_new {
|
if has_new {
|
||||||
let uid_range = format!("{}:*", max_cached_uid + 1);
|
let uid_range = format!("{}:*", max_cached_uid + 1);
|
||||||
@@ -440,21 +395,18 @@ async fn fetch_new_after_idle(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect deletions if server count < cached count.
|
|
||||||
let mut deleted_count = 0u32;
|
let mut deleted_count = 0u32;
|
||||||
if server_exists < cached_count {
|
if server_exists < cached_count {
|
||||||
deleted_count =
|
deleted_count =
|
||||||
fetch_uids_and_remove_deleted(&mut session, user_hash, folder, config).await;
|
fetch_uids_and_remove_deleted(&mut session, user_hash, folder, config).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the latest message's sender/subject if new messages arrived.
|
let (latest_uid, latest_sender, latest_subject) = if new_count > 0 {
|
||||||
let (latest_sender, latest_subject) = if new_count > 0 {
|
|
||||||
lookup_latest_message(config, user_hash, folder)
|
lookup_latest_message(config, user_hash, folder)
|
||||||
} else {
|
} else {
|
||||||
(None, None)
|
(None, None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update folder sync status with new modseq.
|
|
||||||
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||||
&& let Ok(transaction) = header_sync.begin_write(&conn)
|
&& let Ok(transaction) = header_sync.begin_write(&conn)
|
||||||
{
|
{
|
||||||
@@ -486,15 +438,14 @@ async fn fetch_new_after_idle(
|
|||||||
new_count,
|
new_count,
|
||||||
flags_updated,
|
flags_updated,
|
||||||
deleted_count,
|
deleted_count,
|
||||||
|
latest_uid,
|
||||||
latest_sender,
|
latest_sender,
|
||||||
latest_subject,
|
latest_subject,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// CHANGEDSINCE fetch failed — fall through to full scan below.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fallback: full UID scan (no CONDSTORE) ───────────────────────
|
|
||||||
let cached_uids: std::collections::HashSet<u32> =
|
let cached_uids: std::collections::HashSet<u32> =
|
||||||
match db::pool::open_user_db(&config.data_dir, user_hash) {
|
match db::pool::open_user_db(&config.data_dir, user_hash) {
|
||||||
Ok(conn) => {
|
Ok(conn) => {
|
||||||
@@ -508,7 +459,6 @@ async fn fetch_new_after_idle(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch all UIDs from server.
|
|
||||||
let imap_uids: Option<std::collections::HashSet<u32>> =
|
let imap_uids: Option<std::collections::HashSet<u32>> =
|
||||||
match session.uid_fetch("1:*", "UID").await {
|
match session.uid_fetch("1:*", "UID").await {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
@@ -539,7 +489,6 @@ async fn fetch_new_after_idle(
|
|||||||
return (session, ReconcileResult::empty());
|
return (session, ReconcileResult::empty());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch headers for new messages.
|
|
||||||
let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid);
|
let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid);
|
||||||
let mut new_count = 0u32;
|
let mut new_count = 0u32;
|
||||||
if has_new {
|
if has_new {
|
||||||
@@ -556,7 +505,6 @@ async fn fetch_new_after_idle(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete cached UIDs no longer on server.
|
|
||||||
let mut deleted_count = 0u32;
|
let mut deleted_count = 0u32;
|
||||||
let deleted_uids: Vec<u32> = cached_uids
|
let deleted_uids: Vec<u32> = cached_uids
|
||||||
.iter()
|
.iter()
|
||||||
@@ -573,7 +521,6 @@ async fn fetch_new_after_idle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update folder sync status.
|
|
||||||
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
|
||||||
&& let Ok(transaction) = header_sync.begin_write(&conn)
|
&& let Ok(transaction) = header_sync.begin_write(&conn)
|
||||||
{
|
{
|
||||||
@@ -588,11 +535,10 @@ async fn fetch_new_after_idle(
|
|||||||
let _ = transaction.commit();
|
let _ = transaction.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the latest message's sender/subject if new messages arrived.
|
let (latest_uid, latest_sender, latest_subject) = if new_count > 0 {
|
||||||
let (latest_sender, latest_subject) = if new_count > 0 {
|
|
||||||
lookup_latest_message(config, user_hash, folder)
|
lookup_latest_message(config, user_hash, folder)
|
||||||
} else {
|
} else {
|
||||||
(None, None)
|
(None, None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
if new_count > 0 || deleted_count > 0 {
|
if new_count > 0 || deleted_count > 0 {
|
||||||
@@ -611,24 +557,24 @@ async fn fetch_new_after_idle(
|
|||||||
new_count,
|
new_count,
|
||||||
flags_updated: 0,
|
flags_updated: 0,
|
||||||
deleted_count,
|
deleted_count,
|
||||||
|
latest_uid,
|
||||||
latest_sender,
|
latest_sender,
|
||||||
latest_subject,
|
latest_subject,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up the most recent message in the DB for sender/subject details.
|
|
||||||
fn lookup_latest_message(
|
fn lookup_latest_message(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
) -> (Option<String>, Option<String>) {
|
) -> (Option<u32>, Option<String>, Option<String>) {
|
||||||
let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) else {
|
let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) else {
|
||||||
return (None, None);
|
return (None, None, None);
|
||||||
};
|
};
|
||||||
let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0);
|
let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0);
|
||||||
if max_uid == 0 {
|
if max_uid == 0 {
|
||||||
return (None, None);
|
return (None, None, None);
|
||||||
}
|
}
|
||||||
match db::messages::get_single_message(&conn, folder, max_uid) {
|
match db::messages::get_single_message(&conn, folder, max_uid) {
|
||||||
Ok(Some(msg)) => {
|
Ok(Some(msg)) => {
|
||||||
@@ -644,15 +590,12 @@ fn lookup_latest_message(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
(sender, subject)
|
(Some(max_uid), sender, subject)
|
||||||
}
|
}
|
||||||
_ => (None, None),
|
_ => (None, None, None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch full headers for messages in the given UID range and store them in the DB.
|
|
||||||
/// Skips messages with UID ≤ `min_uid` (handles the `*` wildcard returning the last message).
|
|
||||||
/// Returns the number of new messages stored.
|
|
||||||
async fn fetch_and_store_headers(
|
async fn fetch_and_store_headers(
|
||||||
session: &mut async_imap::Session<ImapStream>,
|
session: &mut async_imap::Session<ImapStream>,
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
@@ -706,11 +649,10 @@ async fn fetch_and_store_headers(
|
|||||||
for fetch in &fetches {
|
for fetch in &fetches {
|
||||||
let uid = match fetch.uid {
|
let uid = match fetch.uid {
|
||||||
Some(u) if u > min_uid => u,
|
Some(u) if u > min_uid => u,
|
||||||
Some(_) => continue, // UID "*" can return the last message
|
Some(_) => continue,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse threading headers.
|
|
||||||
let raw_header_bytes = fetch.header();
|
let raw_header_bytes = fetch.header();
|
||||||
let parsed_threading =
|
let parsed_threading =
|
||||||
raw_header_bytes.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
|
raw_header_bytes.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
|
||||||
@@ -770,7 +712,6 @@ async fn fetch_and_store_headers(
|
|||||||
.or_else(|| val.as_text().map(|s| format!("<{s}>")))
|
.or_else(|| val.as_text().map(|s| format!("<{s}>")))
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect Outlook/Exchange reaction emails from headers.
|
|
||||||
let reaction = raw_header_bytes.and_then(|raw| {
|
let reaction = raw_header_bytes.and_then(|raw| {
|
||||||
let header_str = std::str::from_utf8(raw).ok()?;
|
let header_str = std::str::from_utf8(raw).ok()?;
|
||||||
let lower = header_str.to_lowercase();
|
let lower = header_str.to_lowercase();
|
||||||
@@ -828,7 +769,6 @@ async fn fetch_and_store_headers(
|
|||||||
) {
|
) {
|
||||||
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to upsert message");
|
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to upsert message");
|
||||||
} else {
|
} else {
|
||||||
// Populate denormalized known_addresses table.
|
|
||||||
if let Err(e) = db::contacts::populate_known_addresses(
|
if let Err(e) = db::contacts::populate_known_addresses(
|
||||||
&conn,
|
&conn,
|
||||||
from_address,
|
from_address,
|
||||||
@@ -855,9 +795,6 @@ async fn fetch_and_store_headers(
|
|||||||
new_count
|
new_count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch all UIDs from the server and remove cached messages no longer present.
|
|
||||||
/// Used for deletion detection when server count < cached count.
|
|
||||||
/// Returns the number of deleted messages.
|
|
||||||
async fn fetch_uids_and_remove_deleted(
|
async fn fetch_uids_and_remove_deleted(
|
||||||
session: &mut async_imap::Session<ImapStream>,
|
session: &mut async_imap::Session<ImapStream>,
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
@@ -943,7 +880,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let original_task = manager.tasks.get(&key).unwrap().handle.id();
|
let original_task = manager.tasks.get(&key).unwrap().handle.id();
|
||||||
// A reconnect may subscribe before the old socket finishes closing.
|
|
||||||
manager
|
manager
|
||||||
.start_idle(
|
.start_idle(
|
||||||
key.0.clone(),
|
key.0.clone(),
|
||||||
@@ -963,7 +900,6 @@ mod tests {
|
|||||||
manager.stop_idle(&key.0, &key.1).await;
|
manager.stop_idle(&key.0, &key.1).await;
|
||||||
assert!(manager.tasks.is_empty());
|
assert!(manager.tasks.is_empty());
|
||||||
|
|
||||||
// A connection arriving after the last disconnect gets a new task.
|
|
||||||
manager
|
manager
|
||||||
.start_idle(key.0.clone(), key.1.clone(), creds, event_bus, config)
|
.start_idle(key.0.clone(), key.1.clone(), creds, event_bus, config)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
+2
-22
@@ -9,16 +9,8 @@ use crate::realtime::events::{EventBus, MailEvent};
|
|||||||
use crate::search::engine::UserIndex;
|
use crate::search::engine::UserIndex;
|
||||||
use crate::search::engine::{IndexableMessage, SearchEngine};
|
use crate::search::engine::{IndexableMessage, SearchEngine};
|
||||||
|
|
||||||
/// How often to run a sync check (seconds).
|
|
||||||
/// Reconcile every three minutes, including an INBOX safety net if IDLE drops.
|
|
||||||
const SYNC_INTERVAL_SECS: u64 = 180;
|
const SYNC_INTERVAL_SECS: u64 = 180;
|
||||||
|
|
||||||
/// Run a periodic reconciliation loop for a user.
|
|
||||||
///
|
|
||||||
/// Uses a 3-tier strategy per folder:
|
|
||||||
/// 1. STATUS pre-check (cheap, no SELECT)
|
|
||||||
/// 2. CONDSTORE incremental fetch (only changed flags)
|
|
||||||
/// 3. Full fetch fallback (when CONDSTORE unavailable)
|
|
||||||
pub async fn sync_loop(
|
pub async fn sync_loop(
|
||||||
user_hash: String,
|
user_hash: String,
|
||||||
creds: ImapCredentials,
|
creds: ImapCredentials,
|
||||||
@@ -28,7 +20,7 @@ pub async fn sync_loop(
|
|||||||
search_engine: Arc<SearchEngine>,
|
search_engine: Arc<SearchEngine>,
|
||||||
) {
|
) {
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(SYNC_INTERVAL_SECS));
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(SYNC_INTERVAL_SECS));
|
||||||
// Skip the first immediate tick — IDLE + initial list_messages handles that.
|
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -80,7 +72,6 @@ async fn run_sync(
|
|||||||
for folder in &folder_snapshots {
|
for folder in &folder_snapshots {
|
||||||
let folder_name = &folder.name;
|
let folder_name = &folder.name;
|
||||||
|
|
||||||
// ── Tier 1: STATUS pre-check (no SELECT needed) ──────────────
|
|
||||||
let status = match imap_client.folder_status_extended(creds, folder_name).await {
|
let status = match imap_client.folder_status_extended(creds, folder_name).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -93,7 +84,6 @@ async fn run_sync(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check UIDVALIDITY — if changed, the folder was rebuilt.
|
|
||||||
if folder.uid_validity != 0 && status.uid_validity != folder.uid_validity {
|
if folder.uid_validity != 0 && status.uid_validity != folder.uid_validity {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
folder = %folder_name,
|
folder = %folder_name,
|
||||||
@@ -110,7 +100,6 @@ async fn run_sync(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If CONDSTORE is supported (highest_modseq > 0) and matches cached → skip.
|
|
||||||
let cached_modseq = folder.highest_modseq;
|
let cached_modseq = folder.highest_modseq;
|
||||||
if status.highest_modseq > 0 && cached_modseq > 0 && status.highest_modseq == cached_modseq
|
if status.highest_modseq > 0 && cached_modseq > 0 && status.highest_modseq == cached_modseq
|
||||||
{
|
{
|
||||||
@@ -128,7 +117,6 @@ async fn run_sync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tier 2: CONDSTORE incremental fetch ──────────────────────
|
|
||||||
if status.highest_modseq > 0 && cached_modseq > 0 {
|
if status.highest_modseq > 0 && cached_modseq > 0 {
|
||||||
let folder_changed = sync_condstore(
|
let folder_changed = sync_condstore(
|
||||||
config,
|
config,
|
||||||
@@ -147,7 +135,6 @@ async fn run_sync(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tier 3: Full fetch fallback ──────────────────────────────
|
|
||||||
let folder_changed = sync_full(
|
let folder_changed = sync_full(
|
||||||
config,
|
config,
|
||||||
user_hash,
|
user_hash,
|
||||||
@@ -167,7 +154,6 @@ async fn run_sync(
|
|||||||
event_bus.publish(user_hash, MailEvent::FolderUpdated).await;
|
event_bus.publish(user_hash, MailEvent::FolderUpdated).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Deep index phase: fetch & index bodies if enabled ────────────
|
|
||||||
if let Err(e) = index_message_bodies(user_hash, creds, config, imap_client, search_engine).await
|
if let Err(e) = index_message_bodies(user_hash, creds, config, imap_client, search_engine).await
|
||||||
{
|
{
|
||||||
tracing::debug!(error = %e, "Deep index phase skipped or failed");
|
tracing::debug!(error = %e, "Deep index phase skipped or failed");
|
||||||
@@ -176,11 +162,8 @@ async fn run_sync(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Batch size for deep indexing per sync cycle.
|
|
||||||
const DEEP_INDEX_BATCH: u32 = 10;
|
const DEEP_INDEX_BATCH: u32 = 10;
|
||||||
|
|
||||||
/// If the `deep_index` preference is enabled, fetch bodies for messages that
|
|
||||||
/// don't have a cached body yet, cache them, and re-index with body text.
|
|
||||||
async fn index_message_bodies(
|
async fn index_message_bodies(
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
creds: &ImapCredentials,
|
creds: &ImapCredentials,
|
||||||
@@ -265,14 +248,12 @@ async fn index_message_bodies(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lightweight snapshot of a cached folder for the sync loop.
|
|
||||||
struct FolderSnapshot {
|
struct FolderSnapshot {
|
||||||
name: String,
|
name: String,
|
||||||
uid_validity: u32,
|
uid_validity: u32,
|
||||||
highest_modseq: u64,
|
highest_modseq: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CONDSTORE path: fetch only changed flags, detect deletions via count comparison.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn sync_condstore(
|
async fn sync_condstore(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
@@ -284,7 +265,6 @@ async fn sync_condstore(
|
|||||||
status: &crate::imap::types::FolderStatusExtended,
|
status: &crate::imap::types::FolderStatusExtended,
|
||||||
event_bus: &EventBus,
|
event_bus: &EventBus,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
// Fetch only messages whose flags changed since our cached modseq.
|
|
||||||
let (changed, new_modseq) = match imap_client
|
let (changed, new_modseq) = match imap_client
|
||||||
.fetch_changed_flags(creds, folder_name, cached_modseq)
|
.fetch_changed_flags(creds, folder_name, cached_modseq)
|
||||||
.await
|
.await
|
||||||
@@ -380,6 +360,7 @@ async fn sync_condstore(
|
|||||||
MailEvent::NewMessages {
|
MailEvent::NewMessages {
|
||||||
folder: folder_name.to_owned(),
|
folder: folder_name.to_owned(),
|
||||||
count: 0,
|
count: 0,
|
||||||
|
latest_uid: None,
|
||||||
latest_sender: None,
|
latest_sender: None,
|
||||||
latest_subject: None,
|
latest_subject: None,
|
||||||
},
|
},
|
||||||
@@ -401,7 +382,6 @@ async fn sync_condstore(
|
|||||||
Ok(folder_changed)
|
Ok(folder_changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full fetch fallback: fetch all UIDs+FLAGS and reconcile (original behavior).
|
|
||||||
async fn sync_full(
|
async fn sync_full(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
|
|||||||
+25
-5
@@ -14,6 +14,13 @@ use crate::realtime::events::EventBus;
|
|||||||
use crate::realtime::idle::IdleManager;
|
use crate::realtime::idle::IdleManager;
|
||||||
use crate::search::engine::SearchEngine;
|
use crate::search::engine::SearchEngine;
|
||||||
|
|
||||||
|
fn unix_time_millis() -> u128 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_browser_id(cookie_header: &str) -> Option<String> {
|
fn extract_browser_id(cookie_header: &str) -> Option<String> {
|
||||||
for segment in cookie_header.split(';') {
|
for segment in cookie_header.split(';') {
|
||||||
let trimmed = segment.trim();
|
let trimmed = segment.trim();
|
||||||
@@ -116,7 +123,7 @@ async fn handle_socket_multi_account(
|
|||||||
host: session.imap_host.clone(),
|
host: session.imap_host.clone(),
|
||||||
port: session.imap_port,
|
port: session.imap_port,
|
||||||
tls: session.imap_tls,
|
tls: session.imap_tls,
|
||||||
tls_verify: session.imap_tls_verify,
|
tls_verify: session.imap_tls_verify,
|
||||||
email: session.email.clone(),
|
email: session.email.clone(),
|
||||||
password: session.password.clone(),
|
password: session.password.clone(),
|
||||||
};
|
};
|
||||||
@@ -145,13 +152,17 @@ async fn handle_socket_multi_account(
|
|||||||
drop(event_tx);
|
drop(event_tx);
|
||||||
|
|
||||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||||
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
let mut heartbeat_interval = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||||
|
heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
let mut last_pong = tokio::time::Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
Some((account_id, mail_event)) = event_rx.recv() => {
|
Some((account_id, mail_event)) = event_rx.recv() => {
|
||||||
let msg = serde_json::json!({
|
let msg = serde_json::json!({
|
||||||
"accountId": account_id,
|
"accountId": account_id,
|
||||||
|
"eventId": uuid::Uuid::new_v4().to_string(),
|
||||||
|
"sentAt": unix_time_millis(),
|
||||||
"event": mail_event,
|
"event": mail_event,
|
||||||
});
|
});
|
||||||
if let Ok(json) = serde_json::to_string(&msg)
|
if let Ok(json) = serde_json::to_string(&msg)
|
||||||
@@ -164,14 +175,23 @@ async fn handle_socket_multi_account(
|
|||||||
msg = ws_rx.next() => {
|
msg = ws_rx.next() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Close(_))) | None => break,
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
Some(Ok(Message::Pong(_))) => {}
|
Some(Ok(Message::Pong(_))) => { last_pong = tokio::time::Instant::now(); }
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
if serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|value| value.get("type").and_then(|kind| kind.as_str()).map(str::to_owned)).as_deref() == Some("heartbeat_ack") {
|
||||||
|
last_pong = tokio::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(Ok(_)) => {}
|
Some(Ok(_)) => {}
|
||||||
Some(Err(_)) => break,
|
Some(Err(_)) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = ping_interval.tick() => {
|
_ = heartbeat_interval.tick() => {
|
||||||
if ws_tx.send(Message::Ping(vec![].into())).await.is_err() {
|
if last_pong.elapsed() > std::time::Duration::from_secs(75) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let heartbeat = serde_json::json!({ "type": "heartbeat", "timestamp": unix_time_millis() }).to_string();
|
||||||
|
if ws_tx.send(Message::Text(heartbeat.into())).await.is_err() || ws_tx.send(Message::Ping(Vec::new().into())).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ struct DeleteResponse {
|
|||||||
status: String,
|
status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for `POST /api/drafts/{draft_id}/attachments`.
|
|
||||||
///
|
|
||||||
/// Accepts a multipart upload containing one or more files. Each file is
|
|
||||||
/// saved to disk and recorded in the `draft_attachments` table.
|
|
||||||
pub async fn upload_attachment(
|
pub async fn upload_attachment(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -69,7 +65,6 @@ pub async fn upload_attachment(
|
|||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
|
||||||
|
|
||||||
// Ensure draft exists (create it if not — auto-creates on first attachment upload).
|
|
||||||
let existing = db::drafts::get_draft(&conn, &draft_id).map_err(AppError::InternalError)?;
|
let existing = db::drafts::get_draft(&conn, &draft_id).map_err(AppError::InternalError)?;
|
||||||
if existing.is_none() {
|
if existing.is_none() {
|
||||||
db::drafts::upsert_draft(
|
db::drafts::upsert_draft(
|
||||||
@@ -79,7 +74,6 @@ pub async fn upload_attachment(
|
|||||||
.map_err(AppError::InternalError)?;
|
.map_err(AppError::InternalError)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the attachment storage directory.
|
|
||||||
let att_dir = Path::new(&config.data_dir)
|
let att_dir = Path::new(&config.data_dir)
|
||||||
.join(&session.user_hash)
|
.join(&session.user_hash)
|
||||||
.join("attachments")
|
.join("attachments")
|
||||||
@@ -186,9 +180,6 @@ pub async fn upload_attachment(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for `GET /api/drafts/{draft_id}/attachments/{attachment_id}/content`.
|
|
||||||
///
|
|
||||||
/// Serves the raw file content of a draft attachment for inline preview.
|
|
||||||
pub async fn get_attachment_content(
|
pub async fn get_attachment_content(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -217,9 +208,6 @@ pub async fn get_attachment_content(
|
|||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for `DELETE /api/drafts/{draft_id}/attachments/{attachment_id}`.
|
|
||||||
///
|
|
||||||
/// Removes an attachment from the database and deletes the file from disk.
|
|
||||||
pub async fn delete_attachment(
|
pub async fn delete_attachment(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -229,7 +217,6 @@ pub async fn delete_attachment(
|
|||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
|
||||||
|
|
||||||
// Get the attachment record so we can find the file path.
|
|
||||||
let attachments =
|
let attachments =
|
||||||
db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?;
|
db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?;
|
||||||
|
|
||||||
@@ -238,13 +225,11 @@ pub async fn delete_attachment(
|
|||||||
.find(|a| a.id == attachment_id)
|
.find(|a| a.id == attachment_id)
|
||||||
.ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?;
|
||||||
|
|
||||||
// Delete file from disk (best-effort).
|
|
||||||
let file_path = attachment.file_path.clone();
|
let file_path = attachment.file_path.clone();
|
||||||
if let Err(e) = tokio::fs::remove_file(&file_path).await {
|
if let Err(e) = tokio::fs::remove_file(&file_path).await {
|
||||||
tracing::warn!(error = %e, path = %file_path, "Failed to delete attachment file from disk");
|
tracing::warn!(error = %e, path = %file_path, "Failed to delete attachment file from disk");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from DB.
|
|
||||||
db::drafts::delete_draft_attachment(&conn, &attachment_id).map_err(AppError::InternalError)?;
|
db::drafts::delete_draft_attachment(&conn, &attachment_id).map_err(AppError::InternalError)?;
|
||||||
|
|
||||||
Ok(Json(DeleteResponse {
|
Ok(Json(DeleteResponse {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<A
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON body expected on `POST /api/auth/login`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -41,7 +40,6 @@ pub struct LoginRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn same_site_policy(_config: &AppConfig) -> &'static str {
|
fn same_site_policy(_config: &AppConfig) -> &'static str {
|
||||||
// RustBox UI and API are same-origin in RustBox, so Strict is always valid.
|
|
||||||
"Strict"
|
"Strict"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,11 +136,6 @@ fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, Stri
|
|||||||
sessions
|
sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/auth/login`
|
|
||||||
///
|
|
||||||
/// Validates the user's credentials against the configured IMAP server.
|
|
||||||
/// On success, creates a session, provisions the user's data directory,
|
|
||||||
/// and returns session cookies.
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
Extension(store): Extension<Arc<SessionStore>>,
|
Extension(store): Extension<Arc<SessionStore>>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -165,10 +158,6 @@ pub async fn login(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server endpoints are administrator-owned configuration. The login
|
|
||||||
// request supplies only user credentials; per-request host overrides are
|
|
||||||
// intentionally ignored so credentials cannot be redirected to an
|
|
||||||
// arbitrary mail server.
|
|
||||||
let Some(imap_host) = config
|
let Some(imap_host) = config
|
||||||
.imap_host
|
.imap_host
|
||||||
.clone()
|
.clone()
|
||||||
@@ -402,10 +391,6 @@ pub async fn login(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/auth/session`
|
|
||||||
///
|
|
||||||
/// Returns the current user's session information. Requires authentication
|
|
||||||
/// (the `auth_guard` middleware injects `SessionState` into extensions).
|
|
||||||
pub async fn get_session(Extension(session): Extension<SessionState>) -> Response {
|
pub async fn get_session(Extension(session): Extension<SessionState>) -> Response {
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -660,11 +645,6 @@ struct ActiveSessionResponse {
|
|||||||
client_name: String,
|
client_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/auth/sessions`
|
|
||||||
///
|
|
||||||
/// Lists all live RustBox sessions for the currently authenticated user across
|
|
||||||
/// browser sessions. Session tokens and server connection data are never
|
|
||||||
/// returned to the UI.
|
|
||||||
pub async fn list_active_sessions(
|
pub async fn list_active_sessions(
|
||||||
Extension(store): Extension<Arc<SessionStore>>,
|
Extension(store): Extension<Arc<SessionStore>>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -695,7 +675,6 @@ pub async fn list_active_sessions(
|
|||||||
Json(serde_json::json!({ "sessions": sessions })).into_response()
|
Json(serde_json::json!({ "sessions": sessions })).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/auth/sessions/{id}` — revoke another active session.
|
|
||||||
pub async fn revoke_active_session(
|
pub async fn revoke_active_session(
|
||||||
Extension(store): Extension<Arc<SessionStore>>,
|
Extension(store): Extension<Arc<SessionStore>>,
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
@@ -732,8 +711,6 @@ pub async fn revoke_active_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/auth/sessions/revoke-others` — keep the current session and
|
|
||||||
/// revoke every other live session for the same RustBox user.
|
|
||||||
pub async fn revoke_other_sessions(
|
pub async fn revoke_other_sessions(
|
||||||
Extension(store): Extension<Arc<SessionStore>>,
|
Extension(store): Extension<Arc<SessionStore>>,
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
@@ -742,10 +719,6 @@ pub async fn revoke_other_sessions(
|
|||||||
Json(serde_json::json!({ "status": "revoked", "count": revoked })).into_response()
|
Json(serde_json::json!({ "status": "revoked", "count": revoked })).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/auth/logout`
|
|
||||||
///
|
|
||||||
/// Removes the current session from the store and clears the session cookie.
|
|
||||||
/// Requires authentication.
|
|
||||||
pub async fn logout(
|
pub async fn logout(
|
||||||
Extension(store): Extension<Arc<SessionStore>>,
|
Extension(store): Extension<Arc<SessionStore>>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
|
|||||||
+2
-59
@@ -13,20 +13,13 @@ use crate::db;
|
|||||||
use crate::db::calendar::{CreateEvent, UpdateCalendarSettings, UpdateEvent};
|
use crate::db::calendar::{CreateEvent, UpdateCalendarSettings, UpdateEvent};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Query / request types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Query parameters for `GET /api/calendar/events`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ListEventsParams {
|
pub struct ListEventsParams {
|
||||||
/// Start of the time range (ISO 8601).
|
|
||||||
pub start: String,
|
pub start: String,
|
||||||
/// End of the time range (ISO 8601).
|
|
||||||
pub end: String,
|
pub end: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON body for `POST /api/calendar/meeting-templates`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateMeetingTemplateRequest {
|
pub struct CreateMeetingTemplateRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -35,7 +28,6 @@ pub struct CreateMeetingTemplateRequest {
|
|||||||
pub icon: String,
|
pub icon: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response for ICS import.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ImportIcsResponse {
|
pub struct ImportIcsResponse {
|
||||||
pub created: usize,
|
pub created: usize,
|
||||||
@@ -43,10 +35,6 @@ pub struct ImportIcsResponse {
|
|||||||
pub events: Vec<db::calendar::CalendarEvent>,
|
pub events: Vec<db::calendar::CalendarEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Event handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn ics_escape(value: &str) -> String {
|
fn ics_escape(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.replace('\\', "\\\\")
|
.replace('\\', "\\\\")
|
||||||
@@ -162,9 +150,6 @@ fn append_ics_event(out: &mut String, event: &db::calendar::CalendarEvent) {
|
|||||||
out.push_str("END:VEVENT\r\n");
|
out.push_str("END:VEVENT\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/calendar/events?start=...&end=...`
|
|
||||||
///
|
|
||||||
/// List events within a time range.
|
|
||||||
pub async fn list_events(
|
pub async fn list_events(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -179,9 +164,6 @@ pub async fn list_events(
|
|||||||
Ok(Json(serde_json::json!({ "events": events })).into_response())
|
Ok(Json(serde_json::json!({ "events": events })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/calendar/events/export-ics`
|
|
||||||
///
|
|
||||||
/// Export all locally known calendar events as a standard iCalendar file.
|
|
||||||
pub async fn export_ics(
|
pub async fn export_ics(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -192,7 +174,7 @@ pub async fn export_ics(
|
|||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
let mut body = String::from(
|
let mut body = String::from(
|
||||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.4//EN\r\nCALSCALE:GREGORIAN\r\n",
|
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.5//EN\r\nCALSCALE:GREGORIAN\r\n",
|
||||||
);
|
);
|
||||||
for event in &events {
|
for event in &events {
|
||||||
append_ics_event(&mut body, event);
|
append_ics_event(&mut body, event);
|
||||||
@@ -211,9 +193,6 @@ pub async fn export_ics(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/calendar/events/{id}`
|
|
||||||
///
|
|
||||||
/// Get a single event by ID.
|
|
||||||
pub async fn get_event(
|
pub async fn get_event(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -231,9 +210,6 @@ pub async fn get_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/calendar/events`
|
|
||||||
///
|
|
||||||
/// Create a new calendar event.
|
|
||||||
pub async fn create_event(
|
pub async fn create_event(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -258,9 +234,6 @@ pub async fn create_event(
|
|||||||
Ok((axum::http::StatusCode::CREATED, Json(event)).into_response())
|
Ok((axum::http::StatusCode::CREATED, Json(event)).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/calendar/events/{id}`
|
|
||||||
///
|
|
||||||
/// Update an existing calendar event.
|
|
||||||
pub async fn update_event(
|
pub async fn update_event(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -278,9 +251,6 @@ pub async fn update_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/calendar/events/{id}`
|
|
||||||
///
|
|
||||||
/// Delete a calendar event.
|
|
||||||
pub async fn delete_event(
|
pub async fn delete_event(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -299,9 +269,6 @@ pub async fn delete_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/calendar/events/import-ics`
|
|
||||||
///
|
|
||||||
/// Import events from ICS text. Deduplicates by source_uid.
|
|
||||||
pub async fn import_ics(
|
pub async fn import_ics(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -321,7 +288,6 @@ pub async fn import_ics(
|
|||||||
let mut skipped = 0usize;
|
let mut skipped = 0usize;
|
||||||
|
|
||||||
for event_data in parsed {
|
for event_data in parsed {
|
||||||
// Deduplicate by source_uid
|
|
||||||
if let Some(ref uid) = event_data.source_uid
|
if let Some(ref uid) = event_data.source_uid
|
||||||
&& let Ok(Some(_)) = db::calendar::find_event_by_source_uid(&conn, uid)
|
&& let Ok(Some(_)) = db::calendar::find_event_by_source_uid(&conn, uid)
|
||||||
{
|
{
|
||||||
@@ -346,13 +312,6 @@ pub async fn import_ics(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Settings handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `GET /api/calendar/settings`
|
|
||||||
///
|
|
||||||
/// Get calendar settings.
|
|
||||||
pub async fn get_calendar_settings(
|
pub async fn get_calendar_settings(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -366,9 +325,6 @@ pub async fn get_calendar_settings(
|
|||||||
Ok(Json(settings).into_response())
|
Ok(Json(settings).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/calendar/settings`
|
|
||||||
///
|
|
||||||
/// Update calendar settings.
|
|
||||||
pub async fn update_calendar_settings(
|
pub async fn update_calendar_settings(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -383,13 +339,6 @@ pub async fn update_calendar_settings(
|
|||||||
Ok(Json(settings).into_response())
|
Ok(Json(settings).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Meeting template handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `GET /api/calendar/meeting-templates`
|
|
||||||
///
|
|
||||||
/// List all meeting templates.
|
|
||||||
pub async fn list_meeting_templates(
|
pub async fn list_meeting_templates(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -403,9 +352,6 @@ pub async fn list_meeting_templates(
|
|||||||
Ok(Json(serde_json::json!({ "templates": templates })).into_response())
|
Ok(Json(serde_json::json!({ "templates": templates })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/calendar/meeting-templates`
|
|
||||||
///
|
|
||||||
/// Create a new meeting template.
|
|
||||||
pub async fn create_meeting_template(
|
pub async fn create_meeting_template(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -428,9 +374,6 @@ pub async fn create_meeting_template(
|
|||||||
Ok((axum::http::StatusCode::CREATED, Json(template)).into_response())
|
Ok((axum::http::StatusCode::CREATED, Json(template)).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/calendar/meeting-templates/{id}`
|
|
||||||
///
|
|
||||||
/// Delete a meeting template.
|
|
||||||
pub async fn delete_meeting_template(
|
pub async fn delete_meeting_template(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ use crate::db::contact_groups::ContactGroup;
|
|||||||
use crate::db::contacts::Contact;
|
use crate::db::contacts::Contact;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Request / response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateGroupBody {
|
pub struct CreateGroupBody {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -37,11 +33,6 @@ pub struct GroupMembersResponse {
|
|||||||
pub members: Vec<Contact>,
|
pub members: Vec<Contact>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `GET /api/contact-groups`
|
|
||||||
pub async fn list_groups_handler(
|
pub async fn list_groups_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -55,7 +46,6 @@ pub async fn list_groups_handler(
|
|||||||
Ok(Json(ListGroupsResponse { groups }).into_response())
|
Ok(Json(ListGroupsResponse { groups }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/contact-groups`
|
|
||||||
pub async fn create_group_handler(
|
pub async fn create_group_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -76,7 +66,6 @@ pub async fn create_group_handler(
|
|||||||
Ok(Json(serde_json::json!({ "id": id, "name": name })).into_response())
|
Ok(Json(serde_json::json!({ "id": id, "name": name })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/contact-groups/{id}`
|
|
||||||
pub async fn update_group_handler(
|
pub async fn update_group_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -101,7 +90,6 @@ pub async fn update_group_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/contact-groups/{id}`
|
|
||||||
pub async fn delete_group_handler(
|
pub async fn delete_group_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -120,7 +108,6 @@ pub async fn delete_group_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/contact-groups/{id}/members`
|
|
||||||
pub async fn list_members_handler(
|
pub async fn list_members_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -135,7 +122,6 @@ pub async fn list_members_handler(
|
|||||||
Ok(Json(GroupMembersResponse { members }).into_response())
|
Ok(Json(GroupMembersResponse { members }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/contacts/{contact_id}/groups`
|
|
||||||
pub async fn list_contact_groups_handler(
|
pub async fn list_contact_groups_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -148,7 +134,6 @@ pub async fn list_contact_groups_handler(
|
|||||||
Ok(Json(ListGroupsResponse { groups }).into_response())
|
Ok(Json(ListGroupsResponse { groups }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/contact-groups/{id}/members`
|
|
||||||
pub async fn add_member_handler(
|
pub async fn add_member_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -164,7 +149,6 @@ pub async fn add_member_handler(
|
|||||||
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
|
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/contact-groups/{id}/members/{contact_id}`
|
|
||||||
pub async fn remove_member_handler(
|
pub async fn remove_member_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
|
|||||||
+45
-118
@@ -12,34 +12,25 @@ use crate::db;
|
|||||||
use crate::db::contacts::Contact;
|
use crate::db::contacts::Contact;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Query / request types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Query parameters for `GET /api/contacts`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ListContactsParams {
|
pub struct ListContactsParams {
|
||||||
/// Optional search query to filter by name or email.
|
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
/// Maximum number of results (default 50).
|
|
||||||
#[serde(default = "default_limit")]
|
#[serde(default = "default_limit")]
|
||||||
pub limit: u32,
|
pub limit: u32,
|
||||||
/// Offset for pagination (default 0).
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub offset: u32,
|
pub offset: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query parameters for `GET /api/contacts/autocomplete`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct AutocompleteParams {
|
pub struct AutocompleteParams {
|
||||||
/// Search query (required for autocomplete).
|
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
/// Maximum number of suggestions (default 10).
|
|
||||||
#[serde(default = "default_autocomplete_limit")]
|
#[serde(default = "default_autocomplete_limit")]
|
||||||
pub limit: u32,
|
pub limit: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JSON body for `POST /api/contacts`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateContactBody {
|
pub struct CreateContactBody {
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
@@ -91,18 +82,12 @@ fn default_source() -> String {
|
|||||||
"manual".to_string()
|
"manual".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Response envelope for `GET /api/contacts`.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ListContactsResponse {
|
pub struct ListContactsResponse {
|
||||||
pub contacts: Vec<Contact>,
|
pub contacts: Vec<Contact>,
|
||||||
pub total_count: usize,
|
pub total_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single autocomplete suggestion.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AutocompleteSuggestion {
|
pub struct AutocompleteSuggestion {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -110,16 +95,11 @@ pub struct AutocompleteSuggestion {
|
|||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response envelope for `GET /api/contacts/autocomplete`.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AutocompleteResponse {
|
pub struct AutocompleteResponse {
|
||||||
pub suggestions: Vec<AutocompleteSuggestion>,
|
pub suggestions: Vec<AutocompleteSuggestion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DuplicateContactGroup {
|
pub struct DuplicateContactGroup {
|
||||||
@@ -161,10 +141,6 @@ fn normalize_contact_values(values: Vec<String>, primary: &str) -> Vec<String> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/contacts?q=&limit=50&offset=0`
|
|
||||||
///
|
|
||||||
/// Lists contacts with optional search. If `q` is provided, uses
|
|
||||||
/// `search_contacts`; otherwise uses `list_contacts`.
|
|
||||||
pub async fn list_contacts_handler(
|
pub async fn list_contacts_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -195,9 +171,6 @@ pub async fn list_contacts_handler(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/contacts`
|
|
||||||
///
|
|
||||||
/// Creates or updates a contact. Generates a UUID for `id` if not provided.
|
|
||||||
pub async fn create_contact_handler(
|
pub async fn create_contact_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -261,10 +234,6 @@ pub async fn create_contact_handler(
|
|||||||
Ok(Json(saved).into_response())
|
Ok(Json(saved).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/contacts/duplicates`
|
|
||||||
///
|
|
||||||
/// Detect duplicate contacts by primary or alternate e-mail addresses.
|
|
||||||
/// Only groups with at least two distinct contacts are returned.
|
|
||||||
pub async fn duplicate_contacts_handler(
|
pub async fn duplicate_contacts_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -313,10 +282,6 @@ pub async fn duplicate_contacts_handler(
|
|||||||
Ok(Json(serde_json::json!({ "duplicates": groups })).into_response())
|
Ok(Json(serde_json::json!({ "duplicates": groups })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/contacts/merge`
|
|
||||||
///
|
|
||||||
/// Merge source into target, preserving target values where both are present.
|
|
||||||
/// Alternate addresses, groups and the source photo are retained when useful.
|
|
||||||
pub async fn merge_contacts_handler(
|
pub async fn merge_contacts_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -427,10 +392,6 @@ pub async fn merge_contacts_handler(
|
|||||||
Ok(Json(merged).into_response())
|
Ok(Json(merged).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/contacts/autocomplete?q=al&limit=10`
|
|
||||||
///
|
|
||||||
/// Fast autocomplete endpoint. Returns matching contacts as lightweight
|
|
||||||
/// suggestions with only email, name, and source.
|
|
||||||
pub async fn autocomplete_handler(
|
pub async fn autocomplete_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -455,11 +416,9 @@ pub async fn autocomplete_handler(
|
|||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Search contacts first
|
|
||||||
let contacts = db::contacts::search_contacts(&conn, query, params.limit)
|
let contacts = db::contacts::search_contacts(&conn, query, params.limit)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Build suggestions from contacts with deduplication by email (lowercase)
|
|
||||||
let mut seen_emails = std::collections::HashSet::new();
|
let mut seen_emails = std::collections::HashSet::new();
|
||||||
let mut suggestions: Vec<AutocompleteSuggestion> = Vec::new();
|
let mut suggestions: Vec<AutocompleteSuggestion> = Vec::new();
|
||||||
|
|
||||||
@@ -484,7 +443,6 @@ pub async fn autocomplete_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we haven't hit the limit, also search known addresses
|
|
||||||
if suggestions.len() < params.limit as usize {
|
if suggestions.len() < params.limit as usize {
|
||||||
let remaining = params.limit as usize - suggestions.len();
|
let remaining = params.limit as usize - suggestions.len();
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -503,16 +461,11 @@ pub async fn autocomplete_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate to limit in case we went over
|
|
||||||
suggestions.truncate(params.limit as usize);
|
suggestions.truncate(params.limit as usize);
|
||||||
|
|
||||||
Ok(Json(AutocompleteResponse { suggestions }).into_response())
|
Ok(Json(AutocompleteResponse { suggestions }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/contacts/autocomplete/all`
|
|
||||||
///
|
|
||||||
/// Returns all contacts and known addresses for client-side autocomplete.
|
|
||||||
/// Used for prefetching to enable instant fuzzy search without network latency.
|
|
||||||
pub async fn autocomplete_all_handler(
|
pub async fn autocomplete_all_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -564,9 +517,6 @@ pub async fn autocomplete_all_handler(
|
|||||||
Ok(Json(AutocompleteResponse { suggestions }).into_response())
|
Ok(Json(AutocompleteResponse { suggestions }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/contacts/:id`
|
|
||||||
///
|
|
||||||
/// Returns a single contact by id. Returns 404 if not found.
|
|
||||||
pub async fn get_contact_handler(
|
pub async fn get_contact_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -584,9 +534,6 @@ pub async fn get_contact_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/contacts/:id`
|
|
||||||
///
|
|
||||||
/// Deletes a contact by id. Returns 404 if the contact does not exist.
|
|
||||||
pub async fn delete_contact_handler(
|
pub async fn delete_contact_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -619,7 +566,6 @@ fn contact_photo_type(data: &[u8]) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload or replace a contact photo. Only PNG, JPEG and WebP are accepted.
|
|
||||||
pub async fn upload_contact_photo_handler(
|
pub async fn upload_contact_photo_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -664,7 +610,6 @@ pub async fn upload_contact_photo_handler(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a contact photo without exposing its storage representation.
|
|
||||||
pub async fn get_contact_photo_handler(
|
pub async fn get_contact_photo_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -684,7 +629,6 @@ pub async fn get_contact_photo_handler(
|
|||||||
.map_err(|e| AppError::InternalError(format!("Failed to build photo response: {e}")))
|
.map_err(|e| AppError::InternalError(format!("Failed to build photo response: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a contact photo while keeping the contact itself.
|
|
||||||
pub async fn delete_contact_photo_handler(
|
pub async fn delete_contact_photo_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -697,11 +641,6 @@ pub async fn delete_contact_photo_handler(
|
|||||||
Ok(Json(serde_json::json!({ "status": "deleted" })).into_response())
|
Ok(Json(serde_json::json!({ "status": "deleted" })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// vCard import/export
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Escape special characters in a vCard field value.
|
|
||||||
fn vcard_escape(s: &str) -> String {
|
fn vcard_escape(s: &str) -> String {
|
||||||
s.replace('\\', "\\\\")
|
s.replace('\\', "\\\\")
|
||||||
.replace(',', "\\,")
|
.replace(',', "\\,")
|
||||||
@@ -709,7 +648,6 @@ fn vcard_escape(s: &str) -> String {
|
|||||||
.replace('\n', "\\n")
|
.replace('\n', "\\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize a Contact to vCard 3.0 format.
|
|
||||||
fn contact_to_vcard(c: &Contact) -> String {
|
fn contact_to_vcard(c: &Contact) -> String {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
lines.push("BEGIN:VCARD".to_string());
|
lines.push("BEGIN:VCARD".to_string());
|
||||||
@@ -761,7 +699,6 @@ fn contact_to_vcard(c: &Contact) -> String {
|
|||||||
lines.join("\r\n")
|
lines.join("\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A parsed vCard entry with optional fields.
|
|
||||||
struct ParsedVCard {
|
struct ParsedVCard {
|
||||||
name: String,
|
name: String,
|
||||||
email: String,
|
email: String,
|
||||||
@@ -777,11 +714,9 @@ struct ParsedVCard {
|
|||||||
birthday: String,
|
birthday: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse vCard data (potentially multiple entries) from a string.
|
|
||||||
fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
|
fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
// Unfold continuation lines (lines starting with space or tab are continuations).
|
|
||||||
let unfolded = data
|
let unfolded = data
|
||||||
.replace("\r\n ", "")
|
.replace("\r\n ", "")
|
||||||
.replace("\r\n\t", "")
|
.replace("\r\n\t", "")
|
||||||
@@ -847,11 +782,10 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract field name and value (handle parameters like EMAIL;TYPE=WORK:...)
|
|
||||||
let (field_name, value) = if let Some(colon_pos) = line.find(':') {
|
let (field_name, value) = if let Some(colon_pos) = line.find(':') {
|
||||||
let raw_key = &line[..colon_pos];
|
let raw_key = &line[..colon_pos];
|
||||||
let value = &line[colon_pos + 1..];
|
let value = &line[colon_pos + 1..];
|
||||||
// Strip parameters (e.g., EMAIL;TYPE=WORK -> EMAIL)
|
|
||||||
let field = if let Some(semi_pos) = raw_key.find(';') {
|
let field = if let Some(semi_pos) = raw_key.find(';') {
|
||||||
&raw_key[..semi_pos]
|
&raw_key[..semi_pos]
|
||||||
} else {
|
} else {
|
||||||
@@ -862,7 +796,6 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unescape vCard values
|
|
||||||
let value = value
|
let value = value
|
||||||
.replace("\\n", "\n")
|
.replace("\\n", "\n")
|
||||||
.replace("\\,", ",")
|
.replace("\\,", ",")
|
||||||
@@ -870,21 +803,18 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
|
|||||||
.replace("\\\\", "\\");
|
.replace("\\\\", "\\");
|
||||||
|
|
||||||
match field_name.as_str() {
|
match field_name.as_str() {
|
||||||
"FN"
|
"FN" if name.is_empty() => {
|
||||||
if name.is_empty() => {
|
name = value;
|
||||||
name = value;
|
}
|
||||||
}
|
"N" if name.is_empty() => {
|
||||||
"N"
|
let parts: Vec<&str> = value.splitn(5, ';').collect();
|
||||||
// N:Last;First;Middle;Prefix;Suffix — use FN if available, else reconstruct
|
let last = parts.first().unwrap_or(&"").trim();
|
||||||
if name.is_empty() => {
|
let first = parts.get(1).unwrap_or(&"").trim();
|
||||||
let parts: Vec<&str> = value.splitn(5, ';').collect();
|
let combined = format!("{first} {last}").trim().to_string();
|
||||||
let last = parts.first().unwrap_or(&"").trim();
|
if !combined.is_empty() {
|
||||||
let first = parts.get(1).unwrap_or(&"").trim();
|
name = combined;
|
||||||
let combined = format!("{first} {last}").trim().to_string();
|
|
||||||
if !combined.is_empty() {
|
|
||||||
name = combined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
"EMAIL" => {
|
"EMAIL" => {
|
||||||
if email.is_empty() {
|
if email.is_empty() {
|
||||||
email = value;
|
email = value;
|
||||||
@@ -892,27 +822,40 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
|
|||||||
emails.push(value);
|
emails.push(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"ORG"
|
"ORG" if company.is_empty() => {
|
||||||
if company.is_empty() => {
|
company = value;
|
||||||
company = value;
|
}
|
||||||
}
|
"NOTE" if notes.is_empty() => {
|
||||||
"NOTE"
|
notes = value;
|
||||||
if notes.is_empty() => {
|
}
|
||||||
notes = value;
|
|
||||||
}
|
|
||||||
"TEL" => {
|
"TEL" => {
|
||||||
if phone.is_empty() { phone = value; }
|
if phone.is_empty() {
|
||||||
else if !phones.iter().any(|item| item == &value) { phones.push(value); }
|
phone = value;
|
||||||
|
} else if !phones.iter().any(|item| item == &value) {
|
||||||
|
phones.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"TITLE" if job_title.is_empty() => {
|
||||||
|
job_title = value;
|
||||||
}
|
}
|
||||||
"TITLE" if job_title.is_empty() => { job_title = value; }
|
|
||||||
"ADR" => {
|
"ADR" => {
|
||||||
let parts: Vec<&str> = value.split(';').filter(|part| !part.trim().is_empty()).collect();
|
let parts: Vec<&str> = value
|
||||||
|
.split(';')
|
||||||
|
.filter(|part| !part.trim().is_empty())
|
||||||
|
.collect();
|
||||||
let parsed = parts.join(", ");
|
let parsed = parts.join(", ");
|
||||||
if address.is_empty() { address = parsed; }
|
if address.is_empty() {
|
||||||
else if !parsed.is_empty() && !addresses.iter().any(|item| item == &parsed) { addresses.push(parsed); }
|
address = parsed;
|
||||||
|
} else if !parsed.is_empty() && !addresses.iter().any(|item| item == &parsed) {
|
||||||
|
addresses.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"URL" if website.is_empty() => {
|
||||||
|
website = value;
|
||||||
|
}
|
||||||
|
"BDAY" if birthday.is_empty() => {
|
||||||
|
birthday = value;
|
||||||
}
|
}
|
||||||
"URL" if website.is_empty() => { website = value; }
|
|
||||||
"BDAY" if birthday.is_empty() => { birthday = value; }
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -920,9 +863,6 @@ fn parse_vcards(data: &str) -> Vec<ParsedVCard> {
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/contacts/export`
|
|
||||||
///
|
|
||||||
/// Export all contacts as a .vcf file download.
|
|
||||||
pub async fn export_contacts_handler(
|
pub async fn export_contacts_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -949,9 +889,6 @@ pub async fn export_contacts_handler(
|
|||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/contacts/{id}/export`
|
|
||||||
///
|
|
||||||
/// Export a single contact as a .vcf file download.
|
|
||||||
pub async fn export_single_contact_handler(
|
pub async fn export_single_contact_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -966,11 +903,7 @@ pub async fn export_single_contact_handler(
|
|||||||
match contact {
|
match contact {
|
||||||
Some(c) => {
|
Some(c) => {
|
||||||
let vcf = contact_to_vcard(&c);
|
let vcf = contact_to_vcard(&c);
|
||||||
// Strip ASCII control characters (U+0000-U+001F and U+007F) from
|
|
||||||
// the contact name before using it in the Content-Disposition
|
|
||||||
// filename. HeaderValue::from_str routes through is_valid
|
|
||||||
// (accepts 0x09, 0x20-0x7E, 0x80-0xFF; rejects 0x00-0x08,
|
|
||||||
// 0x0A-0x1F, 0x7F), so non-ASCII UTF-8 bytes are fine.
|
|
||||||
let safe_name: String = c.name.chars().filter(|c| !c.is_ascii_control()).collect();
|
let safe_name: String = c.name.chars().filter(|c| !c.is_ascii_control()).collect();
|
||||||
let filename = if safe_name.is_empty() {
|
let filename = if safe_name.is_empty() {
|
||||||
"contact.vcf".to_string()
|
"contact.vcf".to_string()
|
||||||
@@ -991,7 +924,6 @@ pub async fn export_single_contact_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import response with counts.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ImportResponse {
|
pub struct ImportResponse {
|
||||||
pub created: usize,
|
pub created: usize,
|
||||||
@@ -999,15 +931,11 @@ pub struct ImportResponse {
|
|||||||
pub skipped: usize,
|
pub skipped: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/contacts/import`
|
|
||||||
///
|
|
||||||
/// Import contacts from an uploaded .vcf file (multipart form data).
|
|
||||||
pub async fn import_contacts_handler(
|
pub async fn import_contacts_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
// Read the uploaded file content
|
|
||||||
let mut vcf_content = String::new();
|
let mut vcf_content = String::new();
|
||||||
if let Some(field) = multipart
|
if let Some(field) = multipart
|
||||||
.next_field()
|
.next_field()
|
||||||
@@ -1050,7 +978,6 @@ pub async fn import_contacts_handler(
|
|||||||
|
|
||||||
match existing {
|
match existing {
|
||||||
Some(mut c) => {
|
Some(mut c) => {
|
||||||
// Merge only empty fields (don't overwrite manual edits).
|
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
if c.name.is_empty() && !entry.name.is_empty() {
|
if c.name.is_empty() && !entry.name.is_empty() {
|
||||||
c.name = entry.name.clone();
|
c.name = entry.name.clone();
|
||||||
|
|||||||
@@ -70,10 +70,6 @@ pub struct SearchRebuildResponse {
|
|||||||
source_messages: usize,
|
source_messages: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/diagnostics/imap`
|
|
||||||
///
|
|
||||||
/// Returns protocol capabilities for the authenticated account. Host names and
|
|
||||||
/// credentials are deliberately not exposed to the browser.
|
|
||||||
pub async fn imap_diagnostics(
|
pub async fn imap_diagnostics(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||||
@@ -128,10 +124,6 @@ pub async fn imap_diagnostics(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/diagnostics/smtp`
|
|
||||||
///
|
|
||||||
/// Performs an authenticated SMTP connection check using NOOP. Server host
|
|
||||||
/// names and credentials are never returned to the browser.
|
|
||||||
pub async fn smtp_diagnostics(
|
pub async fn smtp_diagnostics(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(smtp_client): Extension<Arc<dyn SmtpClient>>,
|
Extension(smtp_client): Extension<Arc<dyn SmtpClient>>,
|
||||||
@@ -159,10 +151,6 @@ pub async fn smtp_diagnostics(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/diagnostics/imap/reconnect`
|
|
||||||
///
|
|
||||||
/// Drops reusable request-path IMAP connections for the current account and
|
|
||||||
/// verifies a fresh authenticated connection by reading capabilities.
|
|
||||||
pub async fn reconnect_imap(
|
pub async fn reconnect_imap(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||||
@@ -213,10 +201,6 @@ fn index_sources_to_messages(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/diagnostics/sync`
|
|
||||||
///
|
|
||||||
/// Reports only local cache synchronization metadata. No server names, message
|
|
||||||
/// content, credentials or folder names are exposed.
|
|
||||||
pub async fn sync_diagnostics(
|
pub async fn sync_diagnostics(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -265,10 +249,6 @@ pub async fn sync_diagnostics(
|
|||||||
Ok(Json(stats).into_response())
|
Ok(Json(stats).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/diagnostics/search`
|
|
||||||
///
|
|
||||||
/// Reports the current Tantivy document count and the number of cached SQLite
|
|
||||||
/// messages available for a rebuild. This endpoint does not expose message text.
|
|
||||||
pub async fn search_diagnostics(
|
pub async fn search_diagnostics(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -311,10 +291,6 @@ pub async fn search_diagnostics(
|
|||||||
Ok(Json(stats).into_response())
|
Ok(Json(stats).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/diagnostics/search/rebuild`
|
|
||||||
///
|
|
||||||
/// Recreates the current user's Tantivy index exclusively from the local SQLite
|
|
||||||
/// cache. Spam/Junk/Trash folders remain excluded by the normal index policy.
|
|
||||||
pub async fn rebuild_search_index(
|
pub async fn rebuild_search_index(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ fn map_update_error(message: String) -> AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/settings/display`
|
|
||||||
pub async fn get_display_preferences(
|
pub async fn get_display_preferences(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -30,7 +29,6 @@ pub async fn get_display_preferences(
|
|||||||
Ok(Json(prefs).into_response())
|
Ok(Json(prefs).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/settings/display`
|
|
||||||
pub async fn update_display_preferences(
|
pub async fn update_display_preferences(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
|
|||||||
+1
-21
@@ -13,10 +13,6 @@ use crate::error::AppError;
|
|||||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||||
use crate::smtp::client::AttachmentData;
|
use crate::smtp::client::AttachmentData;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Request / Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpsertDraftRequest {
|
pub struct UpsertDraftRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -47,8 +43,7 @@ pub struct UpsertDraftRequest {
|
|||||||
pub pgp_attach_public_key: bool,
|
pub pgp_attach_public_key: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pgp_attach_recipient_keys: bool,
|
pub pgp_attach_recipient_keys: bool,
|
||||||
/// Sync this saved snapshot to the IMAP Drafts folder. Autosave requests
|
|
||||||
/// keep this false so typing does not append a new remote draft every time.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sync_imap: bool,
|
pub sync_imap: bool,
|
||||||
}
|
}
|
||||||
@@ -116,11 +111,6 @@ struct DeleteResponse {
|
|||||||
status: String,
|
status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `POST /api/drafts` — Create or update a draft.
|
|
||||||
pub async fn upsert_draft_handler(
|
pub async fn upsert_draft_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -174,10 +164,6 @@ pub async fn upsert_draft_handler(
|
|||||||
)
|
)
|
||||||
.map_err(AppError::InternalError)?;
|
.map_err(AppError::InternalError)?;
|
||||||
|
|
||||||
// Best-effort: sync deliberate snapshots to IMAP Drafts. Autosaves remain
|
|
||||||
// local so typing never produces a remote message per keystroke. The
|
|
||||||
// server-assigned UID is resolved from UIDNEXT + an application-owned
|
|
||||||
// identity header before an older snapshot is removed.
|
|
||||||
if req.sync_imap && !session.imap_host.trim().is_empty() {
|
if req.sync_imap && !session.imap_host.trim().is_empty() {
|
||||||
let imap_creds = session_imap_credentials(&session);
|
let imap_creds = session_imap_credentials(&session);
|
||||||
let drafts_folder = db::folders::find_folder_by_attribute(&conn, "\\Drafts")
|
let drafts_folder = db::folders::find_folder_by_attribute(&conn, "\\Drafts")
|
||||||
@@ -353,7 +339,6 @@ fn load_snapshot_attachments(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a minimal RFC822 message from draft fields for IMAP APPEND.
|
|
||||||
fn build_draft_rfc822(
|
fn build_draft_rfc822(
|
||||||
req: &UpsertDraftRequest,
|
req: &UpsertDraftRequest,
|
||||||
from_email: &str,
|
from_email: &str,
|
||||||
@@ -377,7 +362,6 @@ fn build_draft_rfc822(
|
|||||||
draft_id,
|
draft_id,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Parse comma-separated address lists.
|
|
||||||
for addr in req.to.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
for addr in req.to.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
if let Ok(mailbox) = addr.parse::<Mailbox>() {
|
if let Ok(mailbox) = addr.parse::<Mailbox>() {
|
||||||
builder = builder.to(mailbox);
|
builder = builder.to(mailbox);
|
||||||
@@ -473,7 +457,6 @@ fn build_draft_rfc822(
|
|||||||
Ok(email.formatted())
|
Ok(email.formatted())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/drafts` — List all drafts.
|
|
||||||
pub async fn list_drafts_handler(
|
pub async fn list_drafts_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -497,7 +480,6 @@ pub async fn list_drafts_handler(
|
|||||||
Ok(Json(serde_json::json!({ "drafts": items })).into_response())
|
Ok(Json(serde_json::json!({ "drafts": items })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/drafts/{id}` — Get a single draft with its attachments.
|
|
||||||
pub async fn get_draft_handler(
|
pub async fn get_draft_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -549,7 +531,6 @@ pub async fn get_draft_handler(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/drafts/{id}` — Delete a draft and its attachments.
|
|
||||||
pub async fn delete_draft_handler(
|
pub async fn delete_draft_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -575,7 +556,6 @@ pub async fn delete_draft_handler(
|
|||||||
return Err(AppError::NotFound("Draft not found".to_string()));
|
return Err(AppError::NotFound("Draft not found".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up attachment files from disk.
|
|
||||||
let att_dir = Path::new(&config.data_dir)
|
let att_dir = Path::new(&config.data_dir)
|
||||||
.join(&session.user_hash)
|
.join(&session.user_hash)
|
||||||
.join("attachments")
|
.join("attachments")
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ use crate::error::AppError;
|
|||||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||||
use crate::search::engine::SearchEngine;
|
use crate::search::engine::SearchEngine;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Request / Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateFolderRequest {
|
pub struct CreateFolderRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -36,19 +32,12 @@ struct MessageResponse {
|
|||||||
status: &'static str,
|
status: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Folder names that must not be renamed or deleted.
|
|
||||||
const SYSTEM_FOLDERS: &[&str] = &["INBOX", "Sent", "Drafts", "Trash", "Junk", "Spam"];
|
const SYSTEM_FOLDERS: &[&str] = &["INBOX", "Sent", "Drafts", "Trash", "Junk", "Spam"];
|
||||||
|
|
||||||
/// Returns `true` if the folder is a system folder (case-insensitive).
|
|
||||||
fn is_system_folder(name: &str) -> bool {
|
fn is_system_folder(name: &str) -> bool {
|
||||||
SYSTEM_FOLDERS.iter().any(|s| s.eq_ignore_ascii_case(name))
|
SYSTEM_FOLDERS.iter().any(|s| s.eq_ignore_ascii_case(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates a folder name. Returns `Ok(())` if valid, or an `AppError::BadRequest`.
|
|
||||||
fn validate_folder_name(name: &str) -> Result<(), AppError> {
|
fn validate_folder_name(name: &str) -> Result<(), AppError> {
|
||||||
if name.trim().is_empty() {
|
if name.trim().is_empty() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
@@ -73,7 +62,6 @@ fn validate_folder_name(name: &str) -> Result<(), AppError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build IMAP credentials from the session and app configuration.
|
|
||||||
fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCredentials, AppError> {
|
fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCredentials, AppError> {
|
||||||
Ok(ImapCredentials {
|
Ok(ImapCredentials {
|
||||||
host: session.imap_host.clone(),
|
host: session.imap_host.clone(),
|
||||||
@@ -85,11 +73,6 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCreden
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `POST /api/folders` -- create a new IMAP folder.
|
|
||||||
pub async fn create_folder(
|
pub async fn create_folder(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -100,13 +83,11 @@ pub async fn create_folder(
|
|||||||
|
|
||||||
let creds = build_creds(&session, &config)?;
|
let creds = build_creds(&session, &config)?;
|
||||||
|
|
||||||
// Create the folder on the IMAP server (also subscribes).
|
|
||||||
imap_client
|
imap_client
|
||||||
.create_folder(&creds, &body.name)
|
.create_folder(&creds, &body.name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::InternalError(format!("IMAP create_folder failed: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("IMAP create_folder failed: {e}")))?;
|
||||||
|
|
||||||
// Cache the new folder locally.
|
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
@@ -116,7 +97,6 @@ pub async fn create_folder(
|
|||||||
Ok(Json(MessageResponse { status: "created" }).into_response())
|
Ok(Json(MessageResponse { status: "created" }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PATCH /api/folders/:name` -- rename an existing folder.
|
|
||||||
pub async fn rename_folder(
|
pub async fn rename_folder(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -139,7 +119,6 @@ pub async fn rename_folder(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::InternalError(format!("IMAP rename_folder failed: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("IMAP rename_folder failed: {e}")))?;
|
||||||
|
|
||||||
// Update the local cache.
|
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
@@ -151,7 +130,6 @@ pub async fn rename_folder(
|
|||||||
Ok(Json(MessageResponse { status: "renamed" }).into_response())
|
Ok(Json(MessageResponse { status: "renamed" }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/folders/:name` -- delete a folder.
|
|
||||||
pub async fn delete_folder(
|
pub async fn delete_folder(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -171,7 +149,6 @@ pub async fn delete_folder(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::InternalError(format!("IMAP delete_folder failed: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("IMAP delete_folder failed: {e}")))?;
|
||||||
|
|
||||||
// Remove from local cache.
|
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
@@ -183,7 +160,6 @@ pub async fn delete_folder(
|
|||||||
Ok(Json(MessageResponse { status: "deleted" }).into_response())
|
Ok(Json(MessageResponse { status: "deleted" }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PATCH /api/folders/:name/subscribe` -- toggle folder subscription.
|
|
||||||
pub async fn subscribe_folder(
|
pub async fn subscribe_folder(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -208,7 +184,6 @@ pub async fn subscribe_folder(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/folders/:name/mark-all-read` -- add \Seen to every message.
|
|
||||||
pub async fn mark_all_read(
|
pub async fn mark_all_read(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -237,7 +212,6 @@ pub async fn mark_all_read(
|
|||||||
Ok(Json(MessageResponse { status: "read" }).into_response())
|
Ok(Json(MessageResponse { status: "read" }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/folders/:name/empty` -- permanently remove every message.
|
|
||||||
pub async fn empty_folder(
|
pub async fn empty_folder(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -281,10 +255,6 @@ pub async fn empty_folder(
|
|||||||
Ok(Json(MessageResponse { status: "emptied" }).into_response())
|
Ok(Json(MessageResponse { status: "emptied" }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+1
-23
@@ -10,13 +10,11 @@ use crate::db;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||||
|
|
||||||
/// Response envelope for `GET /api/folders`.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct FoldersResponse {
|
struct FoldersResponse {
|
||||||
folders: Vec<FolderEntry>,
|
folders: Vec<FolderEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single folder in the response.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct FolderEntry {
|
struct FolderEntry {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -27,24 +25,16 @@ struct FolderEntry {
|
|||||||
unread_count: u32,
|
unread_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many seconds the folder list cache is considered fresh.
|
|
||||||
const FOLDER_LIST_TTL_SECS: u32 = 30;
|
const FOLDER_LIST_TTL_SECS: u32 = 30;
|
||||||
|
|
||||||
/// `GET /api/folders`
|
|
||||||
///
|
|
||||||
/// Lists all IMAP folders for the authenticated user, syncing the result
|
|
||||||
/// into the per-user SQLite cache. If the cache was refreshed within
|
|
||||||
/// `FOLDER_LIST_TTL_SECS` seconds, IMAP is skipped entirely.
|
|
||||||
pub async fn list_folders(
|
pub async fn list_folders(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
// Open the per-user database.
|
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// If the folder cache was updated recently, skip the IMAP round-trip.
|
|
||||||
let cache_fresh = db::folders::is_folder_cache_fresh(&conn, FOLDER_LIST_TTL_SECS)
|
let cache_fresh = db::folders::is_folder_cache_fresh(&conn, FOLDER_LIST_TTL_SECS)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
@@ -53,20 +43,16 @@ pub async fn list_folders(
|
|||||||
host: session.imap_host.clone(),
|
host: session.imap_host.clone(),
|
||||||
port: session.imap_port,
|
port: session.imap_port,
|
||||||
tls: session.imap_tls,
|
tls: session.imap_tls,
|
||||||
tls_verify: session.imap_tls_verify,
|
tls_verify: session.imap_tls_verify,
|
||||||
email: session.email.clone(),
|
email: session.email.clone(),
|
||||||
password: session.password.clone(),
|
password: session.password.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch folders from IMAP server.
|
|
||||||
let imap_folders = imap_client
|
let imap_folders = imap_client
|
||||||
.list_folders(&creds)
|
.list_folders(&creds)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
|
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
|
||||||
|
|
||||||
// Sync each folder into SQLite cache.
|
|
||||||
// Use INSERT OR IGNORE to create new folders without triggering CASCADE on
|
|
||||||
// existing ones, then UPDATE the metadata fields separately.
|
|
||||||
for folder in &imap_folders {
|
for folder in &imap_folders {
|
||||||
let flags_csv = folder.attributes.join(",");
|
let flags_csv = folder.attributes.join(",");
|
||||||
db::folders::insert_folder_if_new(
|
db::folders::insert_folder_if_new(
|
||||||
@@ -78,20 +64,14 @@ pub async fn list_folders(
|
|||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove stale folders that no longer exist on the server.
|
|
||||||
let current_names: Vec<String> = imap_folders.iter().map(|f| f.name.clone()).collect();
|
let current_names: Vec<String> = imap_folders.iter().map(|f| f.name.clone()).collect();
|
||||||
db::folders::remove_stale_folders(&conn, ¤t_names)
|
db::folders::remove_stale_folders(&conn, ¤t_names)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Touch updated_at on all folders so the cache TTL resets.
|
|
||||||
db::folders::touch_all_folders(&conn)
|
db::folders::touch_all_folders(&conn)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh unread counts from cached messages — but skip folders whose
|
|
||||||
// messages cache has been invalidated (messages_updated_at IS NULL).
|
|
||||||
// Those folders have a manually adjusted unread_count (via adjust_unread_count)
|
|
||||||
// that should be preserved until the folder's messages are resynced from IMAP.
|
|
||||||
let all_folders = db::folders::get_all_folders(&conn)
|
let all_folders = db::folders::get_all_folders(&conn)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
for f in &all_folders {
|
for f in &all_folders {
|
||||||
@@ -103,11 +83,9 @@ pub async fn list_folders(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read back from cache to get the refreshed counts.
|
|
||||||
let cached = db::folders::get_all_folders(&conn)
|
let cached = db::folders::get_all_folders(&conn)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Build response from cached data (includes any stored counts).
|
|
||||||
let folders: Vec<FolderEntry> = cached
|
let folders: Vec<FolderEntry> = cached
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| {
|
.map(|f| {
|
||||||
|
|||||||
@@ -13,21 +13,12 @@ pub struct HealthResponse {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for `GET /api/health`.
|
|
||||||
///
|
|
||||||
/// Liveness only: if the process can answer HTTP, it is alive.
|
|
||||||
pub async fn health_check() -> Json<HealthResponse> {
|
pub async fn health_check() -> Json<HealthResponse> {
|
||||||
Json(HealthResponse {
|
Json(HealthResponse {
|
||||||
status: "ok".to_string(),
|
status: "ok".to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for `GET /api/ready`.
|
|
||||||
///
|
|
||||||
/// Readiness additionally verifies that RustBox can create/access its local
|
|
||||||
/// data directory. External IMAP/SMTP servers are intentionally not probed:
|
|
||||||
/// they are account-specific and a remote outage must not make the whole
|
|
||||||
/// RustBox process unready for every account.
|
|
||||||
pub async fn readiness_check(Extension(config): Extension<Arc<AppConfig>>) -> Response {
|
pub async fn readiness_check(Extension(config): Extension<Arc<AppConfig>>) -> Response {
|
||||||
let ready = fs::create_dir_all(&config.data_dir).is_ok()
|
let ready = fs::create_dir_all(&config.data_dir).is_ok()
|
||||||
&& fs::metadata(&config.data_dir).is_ok_and(|metadata| metadata.is_dir());
|
&& fs::metadata(&config.data_dir).is_ok_and(|metadata| metadata.is_dir());
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use crate::db;
|
|||||||
use crate::db::identities::{CreateIdentity, UpdateIdentity};
|
use crate::db::identities::{CreateIdentity, UpdateIdentity};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// `GET /api/identities` — list all sender identities.
|
|
||||||
pub async fn list_identities_handler(
|
pub async fn list_identities_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -23,7 +22,6 @@ pub async fn list_identities_handler(
|
|||||||
Ok(Json(serde_json::json!({ "identities": identities })).into_response())
|
Ok(Json(serde_json::json!({ "identities": identities })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/identities/:id` — get a single identity.
|
|
||||||
pub async fn get_identity_handler(
|
pub async fn get_identity_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -40,7 +38,6 @@ pub async fn get_identity_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/identities` — create a new identity.
|
|
||||||
pub async fn create_identity_handler(
|
pub async fn create_identity_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -60,7 +57,6 @@ pub async fn create_identity_handler(
|
|||||||
Ok((axum::http::StatusCode::CREATED, Json(identity)).into_response())
|
Ok((axum::http::StatusCode::CREATED, Json(identity)).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/identities/:id` — update an identity.
|
|
||||||
pub async fn update_identity_handler(
|
pub async fn update_identity_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -79,7 +75,6 @@ pub async fn update_identity_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/identities/:id` — delete an identity.
|
|
||||||
pub async fn delete_identity_handler(
|
pub async fn delete_identity_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
|
|||||||
+7
-151
@@ -19,10 +19,6 @@ use crate::imap::client::{ImapClient, ImapCredentials};
|
|||||||
use crate::realtime::events::{EventBus, MailEvent};
|
use crate::realtime::events::{EventBus, MailEvent};
|
||||||
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
|
use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: build IMAP credentials from session + config
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCredentials, AppError> {
|
fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCredentials, AppError> {
|
||||||
Ok(ImapCredentials {
|
Ok(ImapCredentials {
|
||||||
host: session.imap_host.clone(),
|
host: session.imap_host.clone(),
|
||||||
@@ -34,26 +30,11 @@ fn build_creds(session: &SessionState, _config: &AppConfig) -> Result<ImapCreden
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: validate IMAP flags from client input
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Returns `true` if `flag` is a syntactically valid IMAP flag.
|
|
||||||
///
|
|
||||||
/// Accepts RFC 3501 system flags (`\Seen`, `\Answered`, `\Flagged`,
|
|
||||||
/// `\Deleted`, `\Draft`, `\Recent`) and keyword atoms (`Junk`,
|
|
||||||
/// `$Forwarded`, `$MDNSent`, etc.).
|
|
||||||
///
|
|
||||||
/// This is a restricted safe subset of the RFC 3501 §9 atom grammar:
|
|
||||||
/// an optional leading `\` followed by `[A-Za-z0-9$_\-.+]`, capped at
|
|
||||||
/// 64 characters. The cap is well above any flag seen in practice and
|
|
||||||
/// keeps the set narrow enough to exclude whitespace, parentheses, and
|
|
||||||
/// control characters without needing to enumerate every RFC exclusion.
|
|
||||||
fn is_valid_flag(flag: &str) -> bool {
|
fn is_valid_flag(flag: &str) -> bool {
|
||||||
if flag.is_empty() || flag.len() > 64 {
|
if flag.is_empty() || flag.len() > 64 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Strip optional leading backslash (system flags like \Seen).
|
|
||||||
let atom = flag.strip_prefix('\\').unwrap_or(flag);
|
let atom = flag.strip_prefix('\\').unwrap_or(flag);
|
||||||
!atom.is_empty()
|
!atom.is_empty()
|
||||||
&& atom
|
&& atom
|
||||||
@@ -70,19 +51,8 @@ fn validate_flags(flags: &[String]) -> Result<(), AppError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// How many seconds a folder's message cache is considered fresh.
|
|
||||||
const FOLDER_MESSAGES_TTL_SECS: u32 = 30;
|
const FOLDER_MESSAGES_TTL_SECS: u32 = 30;
|
||||||
|
|
||||||
/// `GET /api/folders/:folder/messages?page=0&per_page=50`
|
|
||||||
///
|
|
||||||
/// Returns paginated messages using a cache-first strategy:
|
|
||||||
/// 1. If the folder was synced within `FOLDER_MESSAGES_TTL_SECS`, serve from cache.
|
|
||||||
/// 2. Otherwise do a lightweight IMAP SELECT to check for new messages and sync
|
|
||||||
/// only what's new.
|
|
||||||
pub async fn list_messages(
|
pub async fn list_messages(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -154,7 +124,6 @@ pub async fn list_messages(
|
|||||||
if !folder_fresh {
|
if !folder_fresh {
|
||||||
let creds = build_creds(&session, &config)?;
|
let creds = build_creds(&session, &config)?;
|
||||||
|
|
||||||
// Do a lightweight IMAP SELECT to get folder status.
|
|
||||||
let status = imap_client
|
let status = imap_client
|
||||||
.folder_status(&creds, &folder)
|
.folder_status(&creds, &folder)
|
||||||
.await
|
.await
|
||||||
@@ -201,8 +170,6 @@ pub async fn list_messages(
|
|||||||
|| folder_invalidated;
|
|| folder_invalidated;
|
||||||
|
|
||||||
if needs_sync {
|
if needs_sync {
|
||||||
// Cold start: no cached messages at all — fetch the ~100 most recent
|
|
||||||
// synchronously for a fast first paint, then background-sync the rest.
|
|
||||||
if (needs_full_sync || cached_count == 0) && status.exists > 0 {
|
if (needs_full_sync || cached_count == 0) && status.exists > 0 {
|
||||||
let recent_start = status.uid_next.saturating_sub(100).max(1);
|
let recent_start = status.uid_next.saturating_sub(100).max(1);
|
||||||
let recent_range = format!("{recent_start}:*");
|
let recent_range = format!("{recent_start}:*");
|
||||||
@@ -233,7 +200,6 @@ pub async fn list_messages(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::InternalError)?;
|
.map_err(AppError::InternalError)?;
|
||||||
|
|
||||||
// Spawn background task to fetch the remaining older headers.
|
|
||||||
if recent_start > 1 {
|
if recent_start > 1 {
|
||||||
let remaining_range = (1, recent_start - 1);
|
let remaining_range = (1, recent_start - 1);
|
||||||
tokio::spawn(sync_remaining_headers(BackgroundSyncParams {
|
tokio::spawn(sync_remaining_headers(BackgroundSyncParams {
|
||||||
@@ -252,8 +218,6 @@ pub async fn list_messages(
|
|||||||
syncing = true;
|
syncing = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// An interrupted initial sync can leave gaps below the highest
|
|
||||||
// cached UID. Reconcile all server UIDs, fetching only missing ones.
|
|
||||||
let uid_range = (1, status.uid_next.saturating_sub(1));
|
let uid_range = (1, status.uid_next.saturating_sub(1));
|
||||||
|
|
||||||
tokio::spawn(sync_remaining_headers(BackgroundSyncParams {
|
tokio::spawn(sync_remaining_headers(BackgroundSyncParams {
|
||||||
@@ -357,11 +321,6 @@ pub async fn list_messages(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers for eager loading
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Upsert fetched headers into the database and index them for search.
|
|
||||||
fn upsert_and_index_headers(
|
fn upsert_and_index_headers(
|
||||||
conn: &rusqlite::Transaction<'_>,
|
conn: &rusqlite::Transaction<'_>,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
@@ -369,7 +328,6 @@ fn upsert_and_index_headers(
|
|||||||
search_engine: &Arc<SearchEngine>,
|
search_engine: &Arc<SearchEngine>,
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
// Caller holds a checked HeaderSync write transaction through indexing.
|
|
||||||
let sync_keywords = db::mail_preferences::get_preferences(conn)
|
let sync_keywords = db::mail_preferences::get_preferences(conn)
|
||||||
.map(|prefs| prefs.sync_imap_keywords)
|
.map(|prefs| prefs.sync_imap_keywords)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
@@ -411,7 +369,6 @@ fn upsert_and_index_headers(
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Populate denormalized known_addresses table.
|
|
||||||
db::contacts::populate_known_addresses(conn, from_address, from_name, &to_json, &cc_json)
|
db::contacts::populate_known_addresses(conn, from_address, from_name, &to_json, &cc_json)
|
||||||
.map_err(|e| AppError::InternalError(format!("Known addresses error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Known addresses error: {e}")))?;
|
||||||
if sync_keywords {
|
if sync_keywords {
|
||||||
@@ -420,7 +377,6 @@ fn upsert_and_index_headers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index for search (skip Spam/Junk/Trash).
|
|
||||||
if !headers.is_empty()
|
if !headers.is_empty()
|
||||||
&& !UserIndex::is_excluded_folder(folder)
|
&& !UserIndex::is_excluded_folder(folder)
|
||||||
&& let Ok(user_index) = search_engine.open_user_index(user_hash)
|
&& let Ok(user_index) = search_engine.open_user_index(user_hash)
|
||||||
@@ -452,7 +408,6 @@ fn upsert_and_index_headers(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bound each FETCH by existing messages, not by the size of gaps between UIDs.
|
|
||||||
fn header_chunks(uids: &[u32]) -> impl Iterator<Item = String> + '_ {
|
fn header_chunks(uids: &[u32]) -> impl Iterator<Item = String> + '_ {
|
||||||
uids.chunks(250).map(|chunk| {
|
uids.chunks(250).map(|chunk| {
|
||||||
chunk
|
chunk
|
||||||
@@ -463,7 +418,6 @@ fn header_chunks(uids: &[u32]) -> impl Iterator<Item = String> + '_ {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for a background sync task, bundled to avoid too-many-arguments.
|
|
||||||
struct BackgroundSyncParams {
|
struct BackgroundSyncParams {
|
||||||
creds: ImapCredentials,
|
creds: ImapCredentials,
|
||||||
folder: String,
|
folder: String,
|
||||||
@@ -478,8 +432,6 @@ struct BackgroundSyncParams {
|
|||||||
exists: u32,
|
exists: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background task: fetch remaining headers for a folder and notify the
|
|
||||||
/// frontend via EventBus when done. All errors are logged but non-fatal.
|
|
||||||
async fn sync_remaining_headers(params: BackgroundSyncParams) {
|
async fn sync_remaining_headers(params: BackgroundSyncParams) {
|
||||||
let BackgroundSyncParams {
|
let BackgroundSyncParams {
|
||||||
creds,
|
creds,
|
||||||
@@ -567,9 +519,7 @@ async fn sync_remaining_headers(params: BackgroundSyncParams) {
|
|||||||
Ok(superseded)
|
Ok(superseded)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
// A completed mutation may invalidate a backfill in another folder.
|
|
||||||
// Wake the client to retry missing UIDs with fresh IMAP reads, not
|
|
||||||
// the rejected headers. Network errors wait for the next request.
|
|
||||||
if matches!(superseded, Ok(true)) {
|
if matches!(superseded, Ok(true)) {
|
||||||
event_bus
|
event_bus
|
||||||
.publish(&user_hash, MailEvent::FolderUpdated)
|
.publish(&user_hash, MailEvent::FolderUpdated)
|
||||||
@@ -579,10 +529,6 @@ async fn sync_remaining_headers(params: BackgroundSyncParams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/messages/:folder/:uid`
|
|
||||||
///
|
|
||||||
/// Returns the full message detail including body and attachment metadata.
|
|
||||||
/// Fetches from IMAP and caches if not already cached.
|
|
||||||
pub async fn get_message(
|
pub async fn get_message(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -592,16 +538,12 @@ pub async fn get_message(
|
|||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let creds = build_creds(&session, &config)?;
|
let creds = build_creds(&session, &config)?;
|
||||||
|
|
||||||
// Open the per-user database.
|
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Check SQLite cache first.
|
|
||||||
let cached_body = db::messages::get_cached_body(&conn, &folder, uid)
|
let cached_body = db::messages::get_cached_body(&conn, &folder, uid)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Treat a cache hit with missing attachments_json as stale (pre-V006 cache).
|
|
||||||
// Re-fetch from IMAP so attachments and inline images are properly resolved.
|
|
||||||
let usable_cache = cached_body.filter(|c| c.attachments_json.is_some());
|
let usable_cache = cached_body.filter(|c| c.attachments_json.is_some());
|
||||||
let fetched_body = usable_cache.is_none();
|
let fetched_body = usable_cache.is_none();
|
||||||
|
|
||||||
@@ -638,15 +580,11 @@ pub async fn get_message(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fetch from IMAP.
|
|
||||||
let body = imap_client
|
let body = imap_client
|
||||||
.fetch_body(&creds, &folder, uid)
|
.fetch_body(&creds, &folder, uid)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
crate::imap::client::ImapError::MessageNotFound { .. } => {
|
crate::imap::client::ImapError::MessageNotFound { .. } => {
|
||||||
// Message exists in local DB but not on IMAP server —
|
|
||||||
// remove the stale cache entry, search index entry, and
|
|
||||||
// invalidate the folder so the list refreshes.
|
|
||||||
let _ = db::messages::delete_message(&conn, &folder, uid);
|
let _ = db::messages::delete_message(&conn, &folder, uid);
|
||||||
let _ = db::folders::invalidate_folder_freshness(&conn, &folder);
|
let _ = db::folders::invalidate_folder_freshness(&conn, &folder);
|
||||||
if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) {
|
if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) {
|
||||||
@@ -655,9 +593,6 @@ pub async fn get_message(
|
|||||||
AppError::NotFound(format!("Message UID {uid} not found in folder {folder}"))
|
AppError::NotFound(format!("Message UID {uid} not found in folder {folder}"))
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
// No cached body and IMAP is unreachable — this message
|
|
||||||
// can't be served. Remove the stale DB entry so it
|
|
||||||
// disappears from the message list.
|
|
||||||
let _ = db::messages::delete_message(&conn, &folder, uid);
|
let _ = db::messages::delete_message(&conn, &folder, uid);
|
||||||
let _ = db::folders::invalidate_folder_freshness(&conn, &folder);
|
let _ = db::folders::invalidate_folder_freshness(&conn, &folder);
|
||||||
if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) {
|
if let Ok(user_index) = search_engine.open_user_index(&session.user_hash) {
|
||||||
@@ -667,7 +602,6 @@ pub async fn get_message(
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Use HTML directly (frontend sandbox handles security).
|
|
||||||
let sanitized_html = body.text_html.clone();
|
let sanitized_html = body.text_html.clone();
|
||||||
|
|
||||||
let attachment_meta: Vec<AttachmentMeta> = body
|
let attachment_meta: Vec<AttachmentMeta> = body
|
||||||
@@ -683,9 +617,6 @@ pub async fn get_message(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Rewrite cid: URLs in the HTML to inline data URIs so the
|
|
||||||
// sandboxed iframe can display embedded images without needing
|
|
||||||
// network access.
|
|
||||||
let resolved_html = sanitized_html.map(|mut html| {
|
let resolved_html = sanitized_html.map(|mut html| {
|
||||||
for att in &body.attachments {
|
for att in &body.attachments {
|
||||||
if let Some(ref cid) = att.content_id {
|
if let Some(ref cid) = att.content_id {
|
||||||
@@ -701,7 +632,6 @@ pub async fn get_message(
|
|||||||
html
|
html
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serialize attachment metadata for caching.
|
|
||||||
let att_json = serde_json::to_string(&attachment_meta).ok();
|
let att_json = serde_json::to_string(&attachment_meta).ok();
|
||||||
|
|
||||||
let detected_theme = resolved_html
|
let detected_theme = resolved_html
|
||||||
@@ -730,17 +660,12 @@ pub async fn get_message(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the message header from cache (use efficient single-message lookup).
|
|
||||||
// If the header hasn't been synced yet (e.g. DB was cleared and sync is
|
|
||||||
// still running), fall back to parsing the raw headers we already fetched.
|
|
||||||
let msg = db::messages::get_single_message(&conn, &folder, uid)
|
let msg = db::messages::get_single_message(&conn, &folder, uid)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
let msg = match msg {
|
let msg = match msg {
|
||||||
Some(m) => m,
|
Some(m) => m,
|
||||||
None => {
|
None => {
|
||||||
// Parse header fields from raw_headers so we can still return a
|
|
||||||
// useful response even when the message list hasn't been synced.
|
|
||||||
let parsed = mail_parser::MessageParser::default().parse(raw_headers.as_bytes());
|
let parsed = mail_parser::MessageParser::default().parse(raw_headers.as_bytes());
|
||||||
let subject = parsed
|
let subject = parsed
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -817,8 +742,6 @@ pub async fn get_message(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-index message with full body text for search.
|
|
||||||
// Skip indexing for Spam/Junk/Trash folders.
|
|
||||||
if fetched_body
|
if fetched_body
|
||||||
&& let Some(ref text) = body_text
|
&& let Some(ref text) = body_text
|
||||||
&& !UserIndex::is_excluded_folder(&folder)
|
&& !UserIndex::is_excluded_folder(&folder)
|
||||||
@@ -843,7 +766,6 @@ pub async fn get_message(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build thread using full References chain.
|
|
||||||
let thread_messages = if let Some(ref message_id) = msg.message_id {
|
let thread_messages = if let Some(ref message_id) = msg.message_id {
|
||||||
db::messages::get_full_thread(&conn, message_id, msg.references_header.as_deref())
|
db::messages::get_full_thread(&conn, message_id, msg.references_header.as_deref())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -901,9 +823,6 @@ pub async fn get_message(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PATCH /api/messages/:folder/:uid/flags`
|
|
||||||
///
|
|
||||||
/// Updates message flags on the IMAP server and in the SQLite cache.
|
|
||||||
pub async fn update_flags(
|
pub async fn update_flags(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -915,7 +834,6 @@ pub async fn update_flags(
|
|||||||
|
|
||||||
let creds = build_creds(&session, &config)?;
|
let creds = build_creds(&session, &config)?;
|
||||||
|
|
||||||
// Convert flags to &str slices for the IMAP client.
|
|
||||||
let flag_refs: Vec<&str> = body.flags.iter().map(|s| s.as_str()).collect();
|
let flag_refs: Vec<&str> = body.flags.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
if body.add {
|
if body.add {
|
||||||
@@ -930,7 +848,6 @@ pub async fn update_flags(
|
|||||||
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
|
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update SQLite cache: read current flags, add/remove, write back.
|
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
@@ -965,17 +882,12 @@ pub async fn update_flags(
|
|||||||
db::messages::update_message_flags(&conn, &folder, uid, &new_flags_csv)
|
db::messages::update_message_flags(&conn, &folder, uid, &new_flags_csv)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Refresh unread count after flag change.
|
|
||||||
db::folders::refresh_unread_count(&conn, &folder)
|
db::folders::refresh_unread_count(&conn, &folder)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
|
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/messages/move`
|
|
||||||
///
|
|
||||||
/// Moves a message from one folder to another on the IMAP server and
|
|
||||||
/// removes it from the source folder in SQLite cache.
|
|
||||||
pub async fn move_message_handler(
|
pub async fn move_message_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -991,8 +903,6 @@ pub async fn move_message_handler(
|
|||||||
|
|
||||||
let creds = build_creds(&session, &config)?;
|
let creds = build_creds(&session, &config)?;
|
||||||
|
|
||||||
// Snapshot the cached source metadata before the IMAP MOVE removes the source UID.
|
|
||||||
// Do not keep a rusqlite connection alive across an await.
|
|
||||||
let source_snapshot = {
|
let source_snapshot = {
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
@@ -1004,10 +914,6 @@ pub async fn move_message_handler(
|
|||||||
.map(|message| !message.flags.contains("\\Seen"))
|
.map(|message| !message.flags.contains("\\Seen"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// async-imap currently discards COPYUID from UID MOVE. Capture UIDNEXT before
|
|
||||||
// the move, then resolve the new UID from the moved message's exact Message-ID.
|
|
||||||
// If the server cannot provide enough information, the move still succeeds but
|
|
||||||
// the UI will intentionally not offer an unsafe Undo action.
|
|
||||||
let destination_uid_floor = if source_snapshot
|
let destination_uid_floor = if source_snapshot
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|message| message.message_id.as_deref())
|
.and_then(|message| message.message_id.as_deref())
|
||||||
@@ -1022,7 +928,6 @@ pub async fn move_message_handler(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Move on IMAP server.
|
|
||||||
imap_client
|
imap_client
|
||||||
.move_message(&creds, &body.from_folder, body.uid, &body.to_folder)
|
.move_message(&creds, &body.from_folder, body.uid, &body.to_folder)
|
||||||
.await
|
.await
|
||||||
@@ -1068,14 +973,9 @@ pub async fn move_message_handler(
|
|||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Delete from source folder cache. We don't keep the row in the destination
|
|
||||||
// because the UID changes after an IMAP MOVE, and a stale UID would cause
|
|
||||||
// 404s when trying to fetch the message body.
|
|
||||||
db::messages::remove_after_mutation(&conn, &body.from_folder, body.uid)
|
db::messages::remove_after_mutation(&conn, &body.from_folder, body.uid)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Remove the old folder/UID pair from search too. Destination UIDs are
|
|
||||||
// discovered during folder sync; Spam/Junk intentionally remain unindexed.
|
|
||||||
let user_hash = session.user_hash.clone();
|
let user_hash = session.user_hash.clone();
|
||||||
let from_folder = body.from_folder.clone();
|
let from_folder = body.from_folder.clone();
|
||||||
let uid = body.uid;
|
let uid = body.uid;
|
||||||
@@ -1085,25 +985,20 @@ pub async fn move_message_handler(
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if !matches!(search_result, Ok(Ok(()))) {
|
if !matches!(search_result, Ok(Ok(()))) {
|
||||||
// The IMAP move already succeeded: do not trigger a false client rollback.
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
?search_result,
|
?search_result,
|
||||||
"Failed to remove moved message from search index"
|
"Failed to remove moved message from search index"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh source folder unread count (now accurate since the row is gone).
|
|
||||||
db::folders::refresh_unread_count(&conn, &body.from_folder)
|
db::folders::refresh_unread_count(&conn, &body.from_folder)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Bump destination folder unread count if the moved message was unread.
|
|
||||||
if was_unread {
|
if was_unread {
|
||||||
db::folders::adjust_unread_count(&conn, &body.to_folder, 1)
|
db::folders::adjust_unread_count(&conn, &body.to_folder, 1)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate destination folder cache so the next list request forces an
|
|
||||||
// IMAP resync and picks up the moved message with its new UID.
|
|
||||||
db::folders::invalidate_folder_freshness(&conn, &body.to_folder)
|
db::folders::invalidate_folder_freshness(&conn, &body.to_folder)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
@@ -1118,9 +1013,6 @@ pub async fn move_message_handler(
|
|||||||
Ok(Json(serde_json::json!({ "status": "ok", "undo_move": undo_move })).into_response())
|
Ok(Json(serde_json::json!({ "status": "ok", "undo_move": undo_move })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/messages/copy`
|
|
||||||
///
|
|
||||||
/// Copies a message to another IMAP folder and invalidates the destination cache.
|
|
||||||
pub async fn copy_message_handler(
|
pub async fn copy_message_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -1146,9 +1038,6 @@ pub async fn copy_message_handler(
|
|||||||
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
|
Ok(Json(serde_json::json!({ "status": "ok" })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/messages/:folder/:uid/attachments/:attachment_id`
|
|
||||||
///
|
|
||||||
/// Downloads an attachment by its index from the message.
|
|
||||||
pub async fn download_attachment(
|
pub async fn download_attachment(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -1157,12 +1046,10 @@ pub async fn download_attachment(
|
|||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let creds = build_creds(&session, &config)?;
|
let creds = build_creds(&session, &config)?;
|
||||||
|
|
||||||
// Parse the attachment index.
|
|
||||||
let index: usize = attachment_id
|
let index: usize = attachment_id
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| AppError::BadRequest(format!("Invalid attachment id: {attachment_id}")))?;
|
.map_err(|_| AppError::BadRequest(format!("Invalid attachment id: {attachment_id}")))?;
|
||||||
|
|
||||||
// Fetch the full message body from IMAP.
|
|
||||||
let body = imap_client
|
let body = imap_client
|
||||||
.fetch_body(&creds, &folder, uid)
|
.fetch_body(&creds, &folder, uid)
|
||||||
.await
|
.await
|
||||||
@@ -1173,28 +1060,21 @@ pub async fn download_attachment(
|
|||||||
other => AppError::ServiceUnavailable(format!("IMAP error: {other}")),
|
other => AppError::ServiceUnavailable(format!("IMAP error: {other}")),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Find the attachment by index.
|
|
||||||
let attachment = body.attachments.into_iter().nth(index).ok_or_else(|| {
|
let attachment = body.attachments.into_iter().nth(index).ok_or_else(|| {
|
||||||
AppError::NotFound(format!(
|
AppError::NotFound(format!(
|
||||||
"Attachment {attachment_id} not found on message UID {uid}"
|
"Attachment {attachment_id} not found on message UID {uid}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Build the response with appropriate headers.
|
|
||||||
let raw_filename = attachment
|
let raw_filename = attachment
|
||||||
.filename
|
.filename
|
||||||
.unwrap_or_else(|| format!("attachment_{index}"));
|
.unwrap_or_else(|| format!("attachment_{index}"));
|
||||||
// Strip ASCII control characters (U+0000-U+001F and U+007F). MIME
|
|
||||||
// decoders can surface them via quoted-pair escapes in malformed emails,
|
|
||||||
// and HeaderValue rejects those bytes. HeaderValue::from_str routes
|
|
||||||
// through is_valid (accepts 0x09, 0x20-0x7E, 0x80-0xFF; rejects
|
|
||||||
// 0x00-0x08, 0x0A-0x1F, 0x7F), so non-ASCII UTF-8 bytes are fine.
|
|
||||||
let filename: String = raw_filename
|
let filename: String = raw_filename
|
||||||
.chars()
|
.chars()
|
||||||
.filter(|c| !c.is_ascii_control())
|
.filter(|c| !c.is_ascii_control())
|
||||||
.collect();
|
.collect();
|
||||||
// Fall back to a safe default if stripping left nothing (e.g. the
|
|
||||||
// original filename was composed entirely of control characters).
|
|
||||||
let filename = if filename.is_empty() {
|
let filename = if filename.is_empty() {
|
||||||
format!("attachment_{index}")
|
format!("attachment_{index}")
|
||||||
} else {
|
} else {
|
||||||
@@ -1203,8 +1083,6 @@ pub async fn download_attachment(
|
|||||||
let content_type =
|
let content_type =
|
||||||
preview_content_type(&attachment.content_type, Some(&filename), &attachment.data);
|
preview_content_type(&attachment.content_type, Some(&filename), &attachment.data);
|
||||||
|
|
||||||
// Use inline disposition for types the browser can display natively
|
|
||||||
// (PDF, images) so the preview works; use attachment for everything else.
|
|
||||||
let is_inline = content_type == "application/pdf"
|
let is_inline = content_type == "application/pdf"
|
||||||
|| content_type.starts_with("image/")
|
|| content_type.starts_with("image/")
|
||||||
|| content_type.starts_with("text/");
|
|| content_type.starts_with("text/");
|
||||||
@@ -1234,14 +1112,8 @@ fn preview_content_type(content_type: &str, filename: Option<&str>, data: &[u8])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum size accepted for importing a single RFC822/.eml message.
|
|
||||||
const MAX_EML_IMPORT_SIZE: usize = 50 * 1024 * 1024;
|
const MAX_EML_IMPORT_SIZE: usize = 50 * 1024 * 1024;
|
||||||
|
|
||||||
/// `POST /api/folders/:folder/import-eml`
|
|
||||||
///
|
|
||||||
/// Imports one raw RFC822 message into the selected IMAP folder using APPEND.
|
|
||||||
/// The server remains the source of truth: after APPEND the local folder cache
|
|
||||||
/// is invalidated so the next list request discovers the server-assigned UID.
|
|
||||||
pub async fn import_eml(
|
pub async fn import_eml(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -1279,8 +1151,6 @@ pub async fn import_eml(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject arbitrary blobs early. We do not rewrite or normalize the source: the
|
|
||||||
// exact uploaded RFC822 bytes are appended to IMAP after this structural check.
|
|
||||||
if mail_parser::MessageParser::default()
|
if mail_parser::MessageParser::default()
|
||||||
.parse(bytes.as_ref())
|
.parse(bytes.as_ref())
|
||||||
.is_none()
|
.is_none()
|
||||||
@@ -1314,7 +1184,6 @@ pub async fn import_eml(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download the complete RFC822 source as an .eml file.
|
|
||||||
pub async fn download_eml_source(
|
pub async fn download_eml_source(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -1402,9 +1271,6 @@ mod preview_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/messages/:folder/:uid`
|
|
||||||
///
|
|
||||||
/// Permanently removes a message from the IMAP server and SQLite cache.
|
|
||||||
pub async fn delete_message_handler(
|
pub async fn delete_message_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -1413,19 +1279,16 @@ pub async fn delete_message_handler(
|
|||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let creds = build_creds(&session, &config)?;
|
let creds = build_creds(&session, &config)?;
|
||||||
|
|
||||||
// Expunge on IMAP server.
|
|
||||||
imap_client
|
imap_client
|
||||||
.expunge_message(&creds, &folder, uid)
|
.expunge_message(&creds, &folder, uid)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
|
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
|
||||||
|
|
||||||
// Delete from SQLite cache.
|
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
db::messages::remove_after_mutation(&conn, &folder, uid)
|
db::messages::remove_after_mutation(&conn, &folder, uid)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Refresh unread count for folder.
|
|
||||||
db::folders::refresh_unread_count(&conn, &folder)
|
db::folders::refresh_unread_count(&conn, &folder)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
@@ -1539,8 +1402,6 @@ mod sync_tests {
|
|||||||
exists: 1,
|
exists: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The IMAP batch has already been snapshotted. Complete the real handler
|
|
||||||
// before allowing the background writer to see those stale headers.
|
|
||||||
gate.0.notified().await;
|
gate.0.notified().await;
|
||||||
let imap_client: Arc<dyn ImapClient> = client.clone();
|
let imap_client: Arc<dyn ImapClient> = client.clone();
|
||||||
let response = if move_message {
|
let response = if move_message {
|
||||||
@@ -1583,7 +1444,7 @@ mod sync_tests {
|
|||||||
"superseded sync must request fresh missing-UID reconciliation"
|
"superseded sync must request fresh missing-UID reconciliation"
|
||||||
);
|
);
|
||||||
assert!(events.try_recv().is_err());
|
assert!(events.try_recv().is_err());
|
||||||
// A move's index deletion also must not be undone by rejected headers.
|
|
||||||
if move_message {
|
if move_message {
|
||||||
let index = search_engine.open_user_index(user_hash).unwrap();
|
let index = search_engine.open_user_index(user_hash).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1599,8 +1460,6 @@ mod sync_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// New destination UIDs (even the same numeric UID in another folder)
|
|
||||||
// remain valid, as do future arrivals in the source folder.
|
|
||||||
let next_sync = HeaderSync::capture(&conn).unwrap();
|
let next_sync = HeaderSync::capture(&conn).unwrap();
|
||||||
let transaction = next_sync.begin_write(&conn).unwrap();
|
let transaction = next_sync.begin_write(&conn).unwrap();
|
||||||
upsert_and_index_headers(
|
upsert_and_index_headers(
|
||||||
@@ -1744,8 +1603,6 @@ mod sync_tests {
|
|||||||
MailEvent::FolderUpdated
|
MailEvent::FolderUpdated
|
||||||
));
|
));
|
||||||
|
|
||||||
// The next actual list request must recover UIDs below max_cached_uid.
|
|
||||||
// A fresh server snapshot omits only the UID deleted in this folder.
|
|
||||||
let remaining = (1..=1000)
|
let remaining = (1..=1000)
|
||||||
.filter(|uid| other_folder || *uid != 950)
|
.filter(|uid| other_folder || *uid != 950)
|
||||||
.map(sync_header)
|
.map(sync_header)
|
||||||
@@ -1868,7 +1725,6 @@ mod sync_tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn empty_background_sync_records_completion() {
|
async fn empty_background_sync_records_completion() {
|
||||||
// Both an exhausted range and an empty server listing are successful syncs.
|
|
||||||
for uid_range in [(1, 0), (1, 100)] {
|
for uid_range in [(1, 0), (1, 100)] {
|
||||||
let data_dir = tempfile::tempdir().unwrap();
|
let data_dir = tempfile::tempdir().unwrap();
|
||||||
let path = data_dir.path().to_str().unwrap();
|
let path = data_dir.path().to_str().unwrap();
|
||||||
@@ -1893,7 +1749,7 @@ mod sync_tests {
|
|||||||
host: "localhost".into(),
|
host: "localhost".into(),
|
||||||
port: 143,
|
port: 143,
|
||||||
tls: false,
|
tls: false,
|
||||||
tls_verify: false,
|
tls_verify: false,
|
||||||
email: "test@example.com".into(),
|
email: "test@example.com".into(),
|
||||||
password: "test".into(),
|
password: "test".into(),
|
||||||
},
|
},
|
||||||
@@ -1963,7 +1819,7 @@ mod sync_tests {
|
|||||||
host: "localhost".into(),
|
host: "localhost".into(),
|
||||||
port: 143,
|
port: 143,
|
||||||
tls: false,
|
tls: false,
|
||||||
tls_verify: false,
|
tls_verify: false,
|
||||||
email: "test@example.com".into(),
|
email: "test@example.com".into(),
|
||||||
password: "test".into(),
|
password: "test".into(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
pub use crate::email_theme::EmailTheme;
|
pub use crate::email_theme::EmailTheme;
|
||||||
|
|
||||||
/// Default page size for paginated list queries.
|
|
||||||
pub fn default_per_page() -> u32 {
|
pub fn default_per_page() -> u32 {
|
||||||
50
|
50
|
||||||
}
|
}
|
||||||
@@ -11,7 +10,6 @@ pub fn default_sort() -> String {
|
|||||||
"date_desc".to_string()
|
"date_desc".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query parameters for `GET /api/folders/:folder/messages`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub(crate) struct ListMessagesQuery {
|
pub(crate) struct ListMessagesQuery {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -22,14 +20,12 @@ pub(crate) struct ListMessagesQuery {
|
|||||||
pub(crate) sort: String,
|
pub(crate) sort: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `PATCH /api/messages/:folder/:uid/flags`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub(crate) struct UpdateFlagsRequest {
|
pub(crate) struct UpdateFlagsRequest {
|
||||||
pub(crate) flags: Vec<String>,
|
pub(crate) flags: Vec<String>,
|
||||||
pub(crate) add: bool,
|
pub(crate) add: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `POST /api/messages/move`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub(crate) struct MoveMessageRequest {
|
pub(crate) struct MoveMessageRequest {
|
||||||
pub(crate) from_folder: String,
|
pub(crate) from_folder: String,
|
||||||
@@ -44,7 +40,6 @@ pub(crate) struct CopyMessageRequest {
|
|||||||
pub(crate) uid: u32,
|
pub(crate) uid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response envelope for `GET /api/folders/:folder/messages`.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub(crate) struct ListMessagesResponse {
|
pub(crate) struct ListMessagesResponse {
|
||||||
pub(crate) messages: Vec<MessageSummary>,
|
pub(crate) messages: Vec<MessageSummary>,
|
||||||
@@ -55,7 +50,6 @@ pub(crate) struct ListMessagesResponse {
|
|||||||
pub(crate) flagged_on_top: bool,
|
pub(crate) flagged_on_top: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A message summary in the list response.
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct MessageSummary {
|
pub struct MessageSummary {
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
@@ -73,20 +67,16 @@ pub struct MessageSummary {
|
|||||||
pub tags: Vec<crate::db::tags::MessageTag>,
|
pub tags: Vec<crate::db::tags::MessageTag>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An email address entry for the detail response.
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub(crate) struct AddressEntry {
|
pub(crate) struct AddressEntry {
|
||||||
pub(crate) name: Option<String>,
|
pub(crate) name: Option<String>,
|
||||||
pub(crate) address: String,
|
pub(crate) address: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a JSON-encoded address list string (e.g. from the SQLite cache) into
|
|
||||||
/// a `Vec<AddressEntry>`. Returns an empty vec on parse failure.
|
|
||||||
pub(crate) fn parse_address_list(json_str: &str) -> Vec<AddressEntry> {
|
pub(crate) fn parse_address_list(json_str: &str) -> Vec<AddressEntry> {
|
||||||
serde_json::from_str(json_str).unwrap_or_default()
|
serde_json::from_str(json_str).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a comma-separated flags string into a `Vec<String>`.
|
|
||||||
pub(crate) fn parse_flags(flags_csv: &str) -> Vec<String> {
|
pub(crate) fn parse_flags(flags_csv: &str) -> Vec<String> {
|
||||||
if flags_csv.is_empty() {
|
if flags_csv.is_empty() {
|
||||||
return vec![];
|
return vec![];
|
||||||
@@ -98,7 +88,6 @@ pub(crate) fn parse_flags(flags_csv: &str) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response for `GET /api/messages/:folder/:uid`.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub(crate) struct MessageDetailResponse {
|
pub(crate) struct MessageDetailResponse {
|
||||||
pub(crate) uid: u32,
|
pub(crate) uid: u32,
|
||||||
@@ -122,7 +111,6 @@ pub(crate) struct MessageDetailResponse {
|
|||||||
pub(crate) email_theme: Option<EmailTheme>,
|
pub(crate) email_theme: Option<EmailTheme>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A message summary within a thread.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub(crate) struct ThreadMessage {
|
pub(crate) struct ThreadMessage {
|
||||||
pub(crate) uid: u32,
|
pub(crate) uid: u32,
|
||||||
@@ -141,7 +129,6 @@ pub(crate) struct ThreadMessage {
|
|||||||
pub(crate) snippet: String,
|
pub(crate) snippet: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attachment metadata (without the binary data).
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub(crate) struct AttachmentMeta {
|
pub(crate) struct AttachmentMeta {
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
|
|||||||
+5
-48
@@ -48,14 +48,6 @@ use crate::realtime::events::EventBus;
|
|||||||
use crate::realtime::idle::IdleManager;
|
use crate::realtime::idle::IdleManager;
|
||||||
use crate::smtp::client::SmtpClient;
|
use crate::smtp::client::SmtpClient;
|
||||||
|
|
||||||
/// Per-IP key extractor that supports trusted reverse proxies.
|
|
||||||
///
|
|
||||||
/// When the direct peer IP is in the configured `trusted_proxies` list,
|
|
||||||
/// `X-Forwarded-For` is walked from right to left. Trusted proxy hops on the
|
|
||||||
/// right are skipped and the nearest untrusted address becomes the client IP.
|
|
||||||
/// If the header is missing or malformed, the direct peer IP is used. Falls
|
|
||||||
/// back to loopback when `ConnectInfo<SocketAddr>` is unavailable (e.g. in
|
|
||||||
/// unit tests).
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct ProxyAwareIpExtractor {
|
struct ProxyAwareIpExtractor {
|
||||||
trusted_proxies: Vec<TrustedProxy>,
|
trusted_proxies: Vec<TrustedProxy>,
|
||||||
@@ -109,28 +101,6 @@ impl KeyExtractor for ProxyAwareIpExtractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assembles all application routes into an Axum Router.
|
|
||||||
///
|
|
||||||
/// Route layout:
|
|
||||||
/// - `GET /api/health` / `/api/ready` — liveness/readiness (public)
|
|
||||||
/// - `POST /api/auth/login` — login (public, CSRF only)
|
|
||||||
/// - `GET /api/auth/session` — get session (auth_guard + CSRF)
|
|
||||||
/// - `POST /api/auth/logout` — logout (auth_guard + CSRF)
|
|
||||||
/// - `GET /api/folders` — list folders (auth_guard + CSRF)
|
|
||||||
/// - `GET /api/folders/:folder/messages` — list messages (auth_guard + CSRF)
|
|
||||||
/// - `GET /api/messages/:folder/:uid` — get message detail (auth_guard + CSRF)
|
|
||||||
/// - `PATCH /api/messages/:folder/:uid/flags` — update flags (auth_guard + CSRF)
|
|
||||||
/// - `GET /api/messages/:folder/:uid/attachments/:attachment_id` — download attachment (auth_guard + CSRF)
|
|
||||||
/// - `POST /api/messages/move` — move message (auth_guard + CSRF)
|
|
||||||
/// - `DELETE /api/messages/:folder/:uid` — delete message (auth_guard + CSRF)
|
|
||||||
///
|
|
||||||
/// All non-API paths are served by the embedded RustBox UI.
|
|
||||||
/// The UI assets are compiled into the same executable; there is no frontend runtime.
|
|
||||||
///
|
|
||||||
/// Middleware layers:
|
|
||||||
/// - tower-http tracing
|
|
||||||
/// - CSRF protection on auth routes
|
|
||||||
/// - auth_guard on protected routes
|
|
||||||
pub fn create_router(
|
pub fn create_router(
|
||||||
config: Arc<AppConfig>,
|
config: Arc<AppConfig>,
|
||||||
store: Arc<SessionStore>,
|
store: Arc<SessionStore>,
|
||||||
@@ -140,7 +110,6 @@ pub fn create_router(
|
|||||||
event_bus: Arc<EventBus>,
|
event_bus: Arc<EventBus>,
|
||||||
idle_manager: Arc<IdleManager>,
|
idle_manager: Arc<IdleManager>,
|
||||||
) -> Router {
|
) -> Router {
|
||||||
// Rate-limit login: replenish 1 token every 12 s, burst of 5.
|
|
||||||
let governor_conf = GovernorConfigBuilder::default()
|
let governor_conf = GovernorConfigBuilder::default()
|
||||||
.key_extractor(ProxyAwareIpExtractor {
|
.key_extractor(ProxyAwareIpExtractor {
|
||||||
trusted_proxies: config.parsed_trusted_proxies(),
|
trusted_proxies: config.parsed_trusted_proxies(),
|
||||||
@@ -150,15 +119,12 @@ pub fn create_router(
|
|||||||
.finish()
|
.finish()
|
||||||
.expect("valid governor config");
|
.expect("valid governor config");
|
||||||
|
|
||||||
// Public auth route: GovernorLayer (outermost) -> CSRF -> handler.
|
|
||||||
let public_auth = Router::new()
|
let public_auth = Router::new()
|
||||||
.route("/login", post(auth::login))
|
.route("/login", post(auth::login))
|
||||||
.route("/policy", get(auth::auth_policy))
|
.route("/policy", get(auth::auth_policy))
|
||||||
.layer(middleware::from_fn(csrf_protection))
|
.layer(middleware::from_fn(csrf_protection))
|
||||||
.layer(GovernorLayer::new(governor_conf));
|
.layer(GovernorLayer::new(governor_conf));
|
||||||
|
|
||||||
// Browser-bound routes (no auth_guard, no rate limit).
|
|
||||||
// These routes only need the browser cookie, not full auth.
|
|
||||||
let browser_routes = Router::new()
|
let browser_routes = Router::new()
|
||||||
.route("/accounts", get(auth::list_accounts))
|
.route("/accounts", get(auth::list_accounts))
|
||||||
.route(
|
.route(
|
||||||
@@ -166,7 +132,6 @@ pub fn create_router(
|
|||||||
delete(auth::remove_account).layer(middleware::from_fn(csrf_protection)),
|
delete(auth::remove_account).layer(middleware::from_fn(csrf_protection)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Protected auth routes (auth_guard + CSRF).
|
|
||||||
let protected_auth = Router::new()
|
let protected_auth = Router::new()
|
||||||
.route("/session", get(auth::get_session))
|
.route("/session", get(auth::get_session))
|
||||||
.route("/sessions", get(auth::list_active_sessions))
|
.route("/sessions", get(auth::list_active_sessions))
|
||||||
@@ -181,9 +146,9 @@ pub fn create_router(
|
|||||||
.merge(browser_routes)
|
.merge(browser_routes)
|
||||||
.merge(protected_auth);
|
.merge(protected_auth);
|
||||||
|
|
||||||
// Protected data routes (auth_guard + CSRF).
|
let attachment_body_limit = config
|
||||||
// Multipart framing needs a small allowance on top of the configured per-file limit.
|
.max_attachment_size_bytes()
|
||||||
let attachment_body_limit = config.max_attachment_size_bytes().saturating_add(1024 * 1024);
|
.saturating_add(1024 * 1024);
|
||||||
let protected_data = Router::new()
|
let protected_data = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/folders",
|
"/folders",
|
||||||
@@ -240,7 +205,8 @@ pub fn create_router(
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/drafts/{draft_id}/attachments",
|
"/drafts/{draft_id}/attachments",
|
||||||
post(attachments::upload_attachment).layer(DefaultBodyLimit::max(attachment_body_limit)),
|
post(attachments::upload_attachment)
|
||||||
|
.layer(DefaultBodyLimit::max(attachment_body_limit)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/drafts/{draft_id}/attachments/{attachment_id}",
|
"/drafts/{draft_id}/attachments/{attachment_id}",
|
||||||
@@ -482,8 +448,6 @@ pub fn create_router(
|
|||||||
.layer(middleware::from_fn(auth_guard))
|
.layer(middleware::from_fn(auth_guard))
|
||||||
.layer(middleware::from_fn(csrf_protection));
|
.layer(middleware::from_fn(csrf_protection));
|
||||||
|
|
||||||
// WebSocket route — auth is handled inside the handler via cookie,
|
|
||||||
// so it bypasses CSRF and auth_guard middleware.
|
|
||||||
let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler));
|
let ws_route = Router::new().route("/ws", get(crate::realtime::ws::ws_handler));
|
||||||
|
|
||||||
let api_router = Router::new()
|
let api_router = Router::new()
|
||||||
@@ -493,21 +457,14 @@ pub fn create_router(
|
|||||||
.merge(ws_route)
|
.merge(ws_route)
|
||||||
.merge(protected_data);
|
.merge(protected_data);
|
||||||
|
|
||||||
// RustBox serves API and UI from the same Rust process.
|
|
||||||
let inner = Router::new()
|
let inner = Router::new()
|
||||||
.nest("/api", api_router)
|
.nest("/api", api_router)
|
||||||
// An explicit index route is required for the root of a nested base path
|
|
||||||
// (for example `/mail/`). The fallback still handles SPA deep links.
|
|
||||||
.route("/", get(crate::ui::serve_ui))
|
.route("/", get(crate::ui::serve_ui))
|
||||||
.fallback(crate::ui::serve_ui);
|
.fallback(crate::ui::serve_ui);
|
||||||
|
|
||||||
// Use the same base path for routing and authentication cookies.
|
|
||||||
let router = match config.normalized_base_path() {
|
let router = match config.normalized_base_path() {
|
||||||
"/" => inner,
|
"/" => inner,
|
||||||
base_path => {
|
base_path => {
|
||||||
// Axum nests an inner `/` route at the prefix itself (`/mail`),
|
|
||||||
// not at the trailing-slash variant (`/mail/`). Serve that variant
|
|
||||||
// explicitly so both URLs load the same embedded SPA.
|
|
||||||
let base_path_slash = format!("{}/", base_path.trim_end_matches('/'));
|
let base_path_slash = format!("{}/", base_path.trim_end_matches('/'));
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(&base_path_slash, get(crate::ui::serve_ui))
|
.route(&base_path_slash, get(crate::ui::serve_ui))
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use crate::db;
|
|||||||
use crate::db::notification_preferences::UpdateNotificationPreferences;
|
use crate::db::notification_preferences::UpdateNotificationPreferences;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// `GET /api/settings/notifications`
|
|
||||||
pub async fn get_notification_preferences(
|
pub async fn get_notification_preferences(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -23,7 +22,6 @@ pub async fn get_notification_preferences(
|
|||||||
Ok(Json(prefs).into_response())
|
Ok(Json(prefs).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/settings/notifications`
|
|
||||||
pub async fn update_notification_preferences(
|
pub async fn update_notification_preferences(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
|
|||||||
+2
-11
@@ -13,17 +13,11 @@ use crate::imap::client::{ImapClient, ImapCredentials};
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct QuotaResponse {
|
struct QuotaResponse {
|
||||||
/// Storage used in bytes, or null if unavailable.
|
|
||||||
usage_bytes: Option<u64>,
|
usage_bytes: Option<u64>,
|
||||||
/// Storage limit in bytes, or null if unlimited/unavailable.
|
|
||||||
limit_bytes: Option<u64>,
|
limit_bytes: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/quota`
|
|
||||||
///
|
|
||||||
/// Returns the mailbox storage quota for the authenticated user.
|
|
||||||
/// Tries IMAP GETQUOTAROOT first; if no quota is configured, falls back to
|
|
||||||
/// the cached message sizes (an estimate during sync). An empty cache uses IMAP.
|
|
||||||
pub async fn get_quota(
|
pub async fn get_quota(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -38,7 +32,6 @@ pub async fn get_quota(
|
|||||||
password: session.password.clone(),
|
password: session.password.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try IMAP GETQUOTAROOT first.
|
|
||||||
let quota = imap_client.get_quota(&creds).await.unwrap_or(None);
|
let quota = imap_client.get_quota(&creds).await.unwrap_or(None);
|
||||||
|
|
||||||
let resp = match quota {
|
let resp = match quota {
|
||||||
@@ -63,8 +56,7 @@ pub async fn get_quota(
|
|||||||
})
|
})
|
||||||
.into_response());
|
.into_response());
|
||||||
}
|
}
|
||||||
// Empty cache: sum RFC822.SIZE across all folders.
|
|
||||||
// Run SQLite access in spawn_blocking to avoid blocking the executor.
|
|
||||||
let data_dir = config.data_dir.clone();
|
let data_dir = config.data_dir.clone();
|
||||||
let user_hash = session.user_hash.clone();
|
let user_hash = session.user_hash.clone();
|
||||||
let folders: Vec<String> = tokio::task::spawn_blocking(move || {
|
let folders: Vec<String> = tokio::task::spawn_blocking(move || {
|
||||||
@@ -81,7 +73,6 @@ pub async fn get_quota(
|
|||||||
.map_err(|e| AppError::InternalError(format!("Task error: {e}")))?
|
.map_err(|e| AppError::InternalError(format!("Task error: {e}")))?
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Fetch sizes with a 90s overall timeout.
|
|
||||||
let total = tokio::time::timeout(
|
let total = tokio::time::timeout(
|
||||||
Duration::from_secs(90),
|
Duration::from_secs(90),
|
||||||
crate::imap::pool::background(async {
|
crate::imap::pool::background(async {
|
||||||
|
|||||||
+21
-80
@@ -11,11 +11,6 @@ use crate::db;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::search::engine::{SearchEngine, SearchQuery};
|
use crate::search::engine::{SearchEngine, SearchQuery};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Search query parser
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Intermediate result of parsing structured operators out of a query string.
|
|
||||||
struct ParsedQuery {
|
struct ParsedQuery {
|
||||||
text: String,
|
text: String,
|
||||||
subject_only: Option<String>,
|
subject_only: Option<String>,
|
||||||
@@ -29,11 +24,6 @@ struct ParsedQuery {
|
|||||||
flagged: Option<bool>,
|
flagged: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a search query string extracting structured operators like `from:`,
|
|
||||||
/// `to:`, `subject:`, `in:`/`folder:`, `date:`, `after:`, `before:`, and
|
|
||||||
/// `has:attachment`. Remaining text becomes the free-text query.
|
|
||||||
///
|
|
||||||
/// Supports quoted values: `from:"John Doe"`, `subject:"meeting notes"`.
|
|
||||||
fn parse_search_query(input: &str) -> ParsedQuery {
|
fn parse_search_query(input: &str) -> ParsedQuery {
|
||||||
let mut text_parts: Vec<String> = Vec::new();
|
let mut text_parts: Vec<String> = Vec::new();
|
||||||
let mut subject_only: Option<String> = None;
|
let mut subject_only: Option<String> = None;
|
||||||
@@ -88,7 +78,6 @@ fn parse_search_query(input: &str) -> ParsedQuery {
|
|||||||
flagged = Some(false)
|
flagged = Some(false)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Unknown operator, treat as plain text
|
|
||||||
text_parts.push(token);
|
text_parts.push(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,9 +100,6 @@ fn parse_search_query(input: &str) -> ParsedQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tokenize a query string, respecting quoted values attached to operators.
|
|
||||||
/// For example: `from:"John Doe" hello world` yields
|
|
||||||
/// `["from:John Doe", "hello", "world"]`.
|
|
||||||
fn tokenize_query(input: &str) -> Vec<String> {
|
fn tokenize_query(input: &str) -> Vec<String> {
|
||||||
let mut tokens = Vec::new();
|
let mut tokens = Vec::new();
|
||||||
let mut chars = input.chars().peekable();
|
let mut chars = input.chars().peekable();
|
||||||
@@ -126,11 +112,10 @@ fn tokenize_query(input: &str) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
chars.next();
|
chars.next();
|
||||||
} else if ch == '"' {
|
} else if ch == '"' {
|
||||||
// Start of a quoted section
|
chars.next();
|
||||||
chars.next(); // consume opening quote
|
|
||||||
while let Some(&qch) = chars.peek() {
|
while let Some(&qch) = chars.peek() {
|
||||||
if qch == '"' {
|
if qch == '"' {
|
||||||
chars.next(); // consume closing quote
|
chars.next();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
current.push(qch);
|
current.push(qch);
|
||||||
@@ -149,12 +134,11 @@ fn tokenize_query(input: &str) -> Vec<String> {
|
|||||||
tokens
|
tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a token into (operator, value) if it matches `operator:value` pattern.
|
|
||||||
fn split_operator(token: &str) -> Option<(String, String)> {
|
fn split_operator(token: &str) -> Option<(String, String)> {
|
||||||
if let Some(colon_pos) = token.find(':') {
|
if let Some(colon_pos) = token.find(':') {
|
||||||
let op = &token[..colon_pos];
|
let op = &token[..colon_pos];
|
||||||
let val = &token[colon_pos + 1..];
|
let val = &token[colon_pos + 1..];
|
||||||
// Only treat as operator if the operator part is alphabetic and value is non-empty
|
|
||||||
if !op.is_empty() && op.chars().all(|c| c.is_ascii_alphabetic()) && !val.is_empty() {
|
if !op.is_empty() && op.chars().all(|c| c.is_ascii_alphabetic()) && !val.is_empty() {
|
||||||
return Some((op.to_string(), val.to_string()));
|
return Some((op.to_string(), val.to_string()));
|
||||||
}
|
}
|
||||||
@@ -162,15 +146,12 @@ fn split_operator(token: &str) -> Option<(String, String)> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a `YYYY-MM-DD` date string into a start-of-day and end-of-day epoch
|
|
||||||
/// (UTC) pair.
|
|
||||||
fn parse_date_range(date_str: &str) -> Option<(i64, i64)> {
|
fn parse_date_range(date_str: &str) -> Option<(i64, i64)> {
|
||||||
let start = parse_date_start(date_str)?;
|
let start = parse_date_start(date_str)?;
|
||||||
let end = start + 86_400 - 1; // end of day
|
let end = start + 86_400 - 1;
|
||||||
Some((start, end))
|
Some((start, end))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a `YYYY-MM-DD` date string into a start-of-day epoch (UTC).
|
|
||||||
fn parse_date_start(date_str: &str) -> Option<i64> {
|
fn parse_date_start(date_str: &str) -> Option<i64> {
|
||||||
let parts: Vec<&str> = date_str.split('-').collect();
|
let parts: Vec<&str> = date_str.split('-').collect();
|
||||||
if parts.len() != 3 {
|
if parts.len() != 3 {
|
||||||
@@ -184,16 +165,12 @@ fn parse_date_start(date_str: &str) -> Option<i64> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to Unix epoch using a simplified calculation (UTC).
|
|
||||||
// Days from year 1970 to the given date.
|
|
||||||
let mut total_days: i64 = 0;
|
let mut total_days: i64 = 0;
|
||||||
|
|
||||||
// Days from full years
|
|
||||||
for y in 1970..year {
|
for y in 1970..year {
|
||||||
total_days += if is_leap_year(y) { 366 } else { 365 };
|
total_days += if is_leap_year(y) { 366 } else { 365 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Days from full months in the target year
|
|
||||||
let days_in_months = [
|
let days_in_months = [
|
||||||
31,
|
31,
|
||||||
28 + i64::from(is_leap_year(year)),
|
28 + i64::from(is_leap_year(year)),
|
||||||
@@ -221,44 +198,37 @@ fn is_leap_year(y: i64) -> bool {
|
|||||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Query parameters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Query parameters for `GET /api/search`.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SearchParams {
|
pub struct SearchParams {
|
||||||
/// The search text (required, must not be empty).
|
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
/// Optional folder filter.
|
|
||||||
pub folder: Option<String>,
|
pub folder: Option<String>,
|
||||||
/// Optional subject filter.
|
|
||||||
pub subject: Option<String>,
|
pub subject: Option<String>,
|
||||||
/// Optional from address filter.
|
|
||||||
pub from: Option<String>,
|
pub from: Option<String>,
|
||||||
/// Optional to address filter.
|
|
||||||
pub to: Option<String>,
|
pub to: Option<String>,
|
||||||
/// Optional date range start (Unix epoch seconds).
|
|
||||||
pub date_from: Option<i64>,
|
pub date_from: Option<i64>,
|
||||||
/// Optional date range end (Unix epoch seconds).
|
|
||||||
pub date_to: Option<i64>,
|
pub date_to: Option<i64>,
|
||||||
/// Optional attachment filter.
|
|
||||||
pub has_attachment: Option<bool>,
|
pub has_attachment: Option<bool>,
|
||||||
/// Optional unread/read filter.
|
|
||||||
pub unread: Option<bool>,
|
pub unread: Option<bool>,
|
||||||
/// Optional flagged/unflagged filter.
|
|
||||||
pub flagged: Option<bool>,
|
pub flagged: Option<bool>,
|
||||||
/// Maximum number of results (default 50).
|
|
||||||
#[serde(default = "default_limit")]
|
#[serde(default = "default_limit")]
|
||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
/// Offset for pagination (default 0).
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub offset: usize,
|
pub offset: usize,
|
||||||
/// Sort order: "date_desc" (default) or "date_asc".
|
|
||||||
#[serde(default = "default_sort")]
|
#[serde(default = "default_sort")]
|
||||||
pub sort: String,
|
pub sort: String,
|
||||||
/// Materialize every matching cached result. Intended for explicit
|
|
||||||
/// "select all search results" bulk actions, not normal rendering.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub all: bool,
|
pub all: bool,
|
||||||
}
|
}
|
||||||
@@ -271,11 +241,6 @@ fn default_limit() -> usize {
|
|||||||
10_000
|
10_000
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Response envelope for `GET /api/search`.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct SearchResponse {
|
pub struct SearchResponse {
|
||||||
pub results: Vec<SearchResultItem>,
|
pub results: Vec<SearchResultItem>,
|
||||||
@@ -283,7 +248,6 @@ pub struct SearchResponse {
|
|||||||
pub query: String,
|
pub query: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single search result item enriched with message metadata from SQLite.
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct SearchResultItem {
|
pub struct SearchResultItem {
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
@@ -299,21 +263,12 @@ pub struct SearchResultItem {
|
|||||||
pub snippet: String,
|
pub snippet: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handler
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `GET /api/search?q=text&folder=INBOX&from=alice&date_from=...&date_to=...&has_attachment=true&limit=50&offset=0`
|
|
||||||
///
|
|
||||||
/// Searches the user's messages using both SQLite (for header fields) and
|
|
||||||
/// Tantivy (for body text). Results are merged and deduplicated.
|
|
||||||
pub async fn search_messages(
|
pub async fn search_messages(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
Extension(search_engine): Extension<Arc<SearchEngine>>,
|
Extension(search_engine): Extension<Arc<SearchEngine>>,
|
||||||
Query(params): Query<SearchParams>,
|
Query(params): Query<SearchParams>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
// Validate that `q` is provided and non-empty.
|
|
||||||
let query_text = params.q.as_deref().unwrap_or("").trim().to_string();
|
let query_text = params.q.as_deref().unwrap_or("").trim().to_string();
|
||||||
|
|
||||||
let has_direct_filters = params.folder.is_some()
|
let has_direct_filters = params.folder.is_some()
|
||||||
@@ -336,7 +291,6 @@ pub async fn search_messages(
|
|||||||
let requested_limit = params.limit.clamp(1, 10_000);
|
let requested_limit = params.limit.clamp(1, 10_000);
|
||||||
let search_offset = params.offset;
|
let search_offset = params.offset;
|
||||||
|
|
||||||
// Parse structured operators from the query string.
|
|
||||||
let parsed = parse_search_query(&query_text);
|
let parsed = parse_search_query(&query_text);
|
||||||
|
|
||||||
let folder = params.folder.or(parsed.folder);
|
let folder = params.folder.or(parsed.folder);
|
||||||
@@ -349,7 +303,6 @@ pub async fn search_messages(
|
|||||||
let unread = params.unread.or(parsed.unread);
|
let unread = params.unread.or(parsed.unread);
|
||||||
let flagged = params.flagged.or(parsed.flagged);
|
let flagged = params.flagged.or(parsed.flagged);
|
||||||
|
|
||||||
// Run SQLite queries in spawn_blocking to avoid blocking the Tokio executor.
|
|
||||||
let data_dir = config.data_dir.clone();
|
let data_dir = config.data_dir.clone();
|
||||||
let user_hash = session.user_hash.clone();
|
let user_hash = session.user_hash.clone();
|
||||||
let text_clone = parsed.text.clone();
|
let text_clone = parsed.text.clone();
|
||||||
@@ -385,7 +338,6 @@ pub async fn search_messages(
|
|||||||
.map_err(|e| AppError::InternalError(format!("Task error: {e}")))?
|
.map_err(|e| AppError::InternalError(format!("Task error: {e}")))?
|
||||||
.map_err(|e| AppError::InternalError(format!("Search error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Search error: {e}")))?;
|
||||||
|
|
||||||
// Collect SQLite results as SearchResultItems.
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
let mut results: Vec<SearchResultItem> = sqlite_results
|
let mut results: Vec<SearchResultItem> = sqlite_results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -407,8 +359,6 @@ pub async fn search_messages(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Secondary search: Tantivy for body text matches not found by SQLite.
|
|
||||||
// Only add results up to the limit cap.
|
|
||||||
if !parsed.text.is_empty()
|
if !parsed.text.is_empty()
|
||||||
&& results.len() < limit
|
&& results.len() < limit
|
||||||
&& let Ok(user_index) = search_engine.open_user_index(&session.user_hash)
|
&& let Ok(user_index) = search_engine.open_user_index(&session.user_hash)
|
||||||
@@ -425,7 +375,6 @@ pub async fn search_messages(
|
|||||||
offset: if select_all { 0 } else { search_offset },
|
offset: if select_all { 0 } else { search_offset },
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
// Resolve Tantivy UIDs via SQLite in spawn_blocking.
|
|
||||||
let data_dir2 = config.data_dir.clone();
|
let data_dir2 = config.data_dir.clone();
|
||||||
let user_hash2 = session.user_hash.clone();
|
let user_hash2 = session.user_hash.clone();
|
||||||
let remaining = limit.saturating_sub(results.len());
|
let remaining = limit.saturating_sub(results.len());
|
||||||
@@ -480,7 +429,6 @@ pub async fn search_messages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort results by date.
|
|
||||||
let sort_asc = sort_order == "date_asc";
|
let sort_asc = sort_order == "date_asc";
|
||||||
results.sort_by(|a, b| {
|
results.sort_by(|a, b| {
|
||||||
let da = crate::db::messages::parse_date_to_epoch_public(&a.date);
|
let da = crate::db::messages::parse_date_to_epoch_public(&a.date);
|
||||||
@@ -492,7 +440,6 @@ pub async fn search_messages(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cap to requested limit after sorting.
|
|
||||||
results.truncate(limit);
|
results.truncate(limit);
|
||||||
|
|
||||||
Ok(Json(SearchResponse {
|
Ok(Json(SearchResponse {
|
||||||
@@ -566,10 +513,6 @@ pub async fn delete_saved_search(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -628,7 +571,7 @@ mod tests {
|
|||||||
assert_eq!(p.text, "news");
|
assert_eq!(p.text, "news");
|
||||||
assert!(p.date_from.is_some());
|
assert!(p.date_from.is_some());
|
||||||
assert!(p.date_to.is_some());
|
assert!(p.date_to.is_some());
|
||||||
// 2024-01-15 00:00:00 UTC = 1705276800
|
|
||||||
assert_eq!(p.date_from, Some(1_705_276_800));
|
assert_eq!(p.date_from, Some(1_705_276_800));
|
||||||
assert_eq!(p.date_to, Some(1_705_276_800 + 86_399));
|
assert_eq!(p.date_to, Some(1_705_276_800 + 86_399));
|
||||||
}
|
}
|
||||||
@@ -637,9 +580,9 @@ mod tests {
|
|||||||
fn parse_after_before() {
|
fn parse_after_before() {
|
||||||
let p = parse_search_query("after:2024-01-01 before:2024-06-01 hello");
|
let p = parse_search_query("after:2024-01-01 before:2024-06-01 hello");
|
||||||
assert_eq!(p.text, "hello");
|
assert_eq!(p.text, "hello");
|
||||||
// 2024-01-01 = 1704067200
|
|
||||||
assert_eq!(p.date_from, Some(1_704_067_200));
|
assert_eq!(p.date_from, Some(1_704_067_200));
|
||||||
// 2024-06-01 = 1717200000
|
|
||||||
assert_eq!(p.date_to, Some(1_717_200_000));
|
assert_eq!(p.date_to, Some(1_717_200_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,7 +631,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_empty_value_not_operator() {
|
fn parse_empty_value_not_operator() {
|
||||||
// "from:" with no value should not be treated as an operator
|
|
||||||
let p = parse_search_query("hello from:");
|
let p = parse_search_query("hello from:");
|
||||||
assert_eq!(p.text, "hello from:");
|
assert_eq!(p.text, "hello from:");
|
||||||
assert!(p.from.is_none());
|
assert!(p.from.is_none());
|
||||||
@@ -696,9 +638,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn date_parsing_basic() {
|
fn date_parsing_basic() {
|
||||||
// 2024-01-01 00:00:00 UTC
|
|
||||||
assert_eq!(parse_date_start("2024-01-01"), Some(1_704_067_200));
|
assert_eq!(parse_date_start("2024-01-01"), Some(1_704_067_200));
|
||||||
// Invalid date
|
|
||||||
assert_eq!(parse_date_start("not-a-date"), None);
|
assert_eq!(parse_date_start("not-a-date"), None);
|
||||||
assert_eq!(parse_date_start("2024-13-01"), None);
|
assert_eq!(parse_date_start("2024-13-01"), None);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-39
@@ -26,29 +26,26 @@ pub struct SendRequest {
|
|||||||
pub html_body: Option<String>,
|
pub html_body: Option<String>,
|
||||||
pub in_reply_to: Option<String>,
|
pub in_reply_to: Option<String>,
|
||||||
pub references: Option<String>,
|
pub references: Option<String>,
|
||||||
/// If sending from a draft, include the draft ID to load attachments
|
|
||||||
/// and clean up after send.
|
|
||||||
pub draft_id: Option<String>,
|
pub draft_id: Option<String>,
|
||||||
/// Optional identity ID to use as the From address.
|
|
||||||
/// If not provided, falls back to the session email.
|
|
||||||
pub from_identity_id: Option<i64>,
|
pub from_identity_id: Option<i64>,
|
||||||
/// Request an MDN/read receipt at the selected identity/account address.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub request_mdn: bool,
|
pub request_mdn: bool,
|
||||||
/// Message priority: high, normal, or low.
|
|
||||||
#[serde(default = "default_priority")]
|
#[serde(default = "default_priority")]
|
||||||
pub priority: String,
|
pub priority: String,
|
||||||
/// OpenPGP mode: none, sign, encrypt, or sign-encrypt. When omitted,
|
|
||||||
/// identity defaults are used.
|
|
||||||
pub pgp_mode: Option<String>,
|
pub pgp_mode: Option<String>,
|
||||||
/// Override identity default for legacy inline OpenPGP. PGP/MIME is the default.
|
|
||||||
pub pgp_inline: Option<bool>,
|
pub pgp_inline: Option<bool>,
|
||||||
/// Passphrase for the selected private key. Never persisted.
|
|
||||||
pub pgp_passphrase: Option<String>,
|
pub pgp_passphrase: Option<String>,
|
||||||
/// Attach the sender's public OpenPGP key as application/pgp-keys.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pgp_attach_public_key: bool,
|
pub pgp_attach_public_key: bool,
|
||||||
/// Attach public OpenPGP keys for visible To/Cc recipients. Bcc is intentionally excluded.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pgp_attach_recipient_keys: bool,
|
pub pgp_attach_recipient_keys: bool,
|
||||||
}
|
}
|
||||||
@@ -70,10 +67,6 @@ pub struct SendResponse {
|
|||||||
pub message_id: String,
|
pub message_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for `POST /api/messages/send`.
|
|
||||||
///
|
|
||||||
/// Validates the request, sends the message via SMTP, and appends a copy
|
|
||||||
/// to the IMAP "Sent" folder (best-effort).
|
|
||||||
pub async fn send_message_handler(
|
pub async fn send_message_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -85,14 +78,12 @@ pub async fn send_message_handler(
|
|||||||
db::drafts::validate_draft_id(draft_id).map_err(AppError::BadRequest)?;
|
db::drafts::validate_draft_id(draft_id).map_err(AppError::BadRequest)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate: at least one recipient.
|
|
||||||
if req.to.is_empty() && req.cc.is_empty() && req.bcc.is_empty() {
|
if req.to.is_empty() && req.cc.is_empty() && req.bcc.is_empty() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"At least one recipient is required".to_string(),
|
"At least one recipient is required".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate: subject or body must be non-empty.
|
|
||||||
let has_subject = !req.subject.trim().is_empty();
|
let has_subject = !req.subject.trim().is_empty();
|
||||||
let has_text = req
|
let has_text = req
|
||||||
.text_body
|
.text_body
|
||||||
@@ -115,10 +106,6 @@ pub async fn send_message_handler(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SMTP credentials from the authenticated account session.
|
|
||||||
// Server endpoints are resolved from RustBox configuration at login and stored in the session.
|
|
||||||
// Normal login falls back from an unset smtp_host to the IMAP host, but reject
|
|
||||||
// an empty endpoint defensively for stale/legacy sessions.
|
|
||||||
if session.smtp_host.trim().is_empty() {
|
if session.smtp_host.trim().is_empty() {
|
||||||
return Err(AppError::ServiceUnavailable(
|
return Err(AppError::ServiceUnavailable(
|
||||||
"SMTP server is not configured for this account".to_string(),
|
"SMTP server is not configured for this account".to_string(),
|
||||||
@@ -133,7 +120,6 @@ pub async fn send_message_handler(
|
|||||||
password: session.password.clone(),
|
password: session.password.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve identity and OpenPGP options before the first network await.
|
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
|
||||||
let selected_identity = if let Some(identity_id) = req.from_identity_id {
|
let selected_identity = if let Some(identity_id) = req.from_identity_id {
|
||||||
@@ -231,8 +217,6 @@ pub async fn send_message_handler(
|
|||||||
)?;
|
)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
// Validate every envelope/header mailbox before touching SMTP. Lettre then
|
|
||||||
// performs the actual SMTPUTF8 capability negotiation during delivery.
|
|
||||||
validate_mailbox("From", &from_address)?;
|
validate_mailbox("From", &from_address)?;
|
||||||
for address in &req.to {
|
for address in &req.to {
|
||||||
validate_mailbox("To", address)?;
|
validate_mailbox("To", address)?;
|
||||||
@@ -250,7 +234,6 @@ pub async fn send_message_handler(
|
|||||||
validate_mailbox("read receipt", &receipt_address)?;
|
validate_mailbox("read receipt", &receipt_address)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load attachments from draft if draft_id is provided.
|
|
||||||
let mut attachments = if let Some(ref draft_id) = req.draft_id {
|
let mut attachments = if let Some(ref draft_id) = req.draft_id {
|
||||||
load_draft_attachments(&config.data_dir, &session.user_hash, draft_id)?
|
load_draft_attachments(&config.data_dir, &session.user_hash, draft_id)?
|
||||||
} else {
|
} else {
|
||||||
@@ -283,7 +266,6 @@ pub async fn send_message_handler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the sendable message.
|
|
||||||
let message = SendableMessage {
|
let message = SendableMessage {
|
||||||
from: from_address,
|
from: from_address,
|
||||||
to: req.to,
|
to: req.to,
|
||||||
@@ -306,8 +288,6 @@ pub async fn send_message_handler(
|
|||||||
max_message_size_bytes: config.max_message_size_bytes(),
|
max_message_size_bytes: config.max_message_size_bytes(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send via SMTP. The exact RFC822 bytes returned here are also appended
|
|
||||||
// to IMAP Sent so PGP/MIME signatures and encryption remain identical.
|
|
||||||
let sent = smtp_client
|
let sent = smtp_client
|
||||||
.send_message(&smtp_creds, &message)
|
.send_message(&smtp_creds, &message)
|
||||||
.await
|
.await
|
||||||
@@ -323,8 +303,6 @@ pub async fn send_message_handler(
|
|||||||
})?;
|
})?;
|
||||||
let message_id = sent.message_id.clone();
|
let message_id = sent.message_id.clone();
|
||||||
|
|
||||||
// Best-effort: append a copy to the "Sent" folder via the account's IMAP server.
|
|
||||||
// Don't fail the SMTP send if this step fails.
|
|
||||||
let imap_creds = ImapCredentials {
|
let imap_creds = ImapCredentials {
|
||||||
host: session.imap_host.clone(),
|
host: session.imap_host.clone(),
|
||||||
port: session.imap_port,
|
port: session.imap_port,
|
||||||
@@ -364,8 +342,6 @@ pub async fn send_message_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the remote Drafts snapshot only after SMTP delivery succeeds.
|
|
||||||
// The UID is verified against the stored UIDVALIDITY before deletion.
|
|
||||||
if let Some(ref draft_id) = req.draft_id {
|
if let Some(ref draft_id) = req.draft_id {
|
||||||
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
&& let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id)
|
&& let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id)
|
||||||
@@ -387,7 +363,6 @@ pub async fn send_message_handler(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load attachment data from disk for a given draft.
|
|
||||||
fn load_draft_attachments(
|
fn load_draft_attachments(
|
||||||
data_dir: &str,
|
data_dir: &str,
|
||||||
user_hash: &str,
|
user_hash: &str,
|
||||||
@@ -417,16 +392,13 @@ fn load_draft_attachments(
|
|||||||
Ok(attachments)
|
Ok(attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up draft record and attachment files from disk after successful send.
|
|
||||||
async fn cleanup_draft(data_dir: &str, user_hash: &str, draft_id: &str) {
|
async fn cleanup_draft(data_dir: &str, user_hash: &str, draft_id: &str) {
|
||||||
// Delete draft from DB (cascade deletes attachment records).
|
|
||||||
if let Ok(conn) = db::pool::open_user_db(data_dir, user_hash)
|
if let Ok(conn) = db::pool::open_user_db(data_dir, user_hash)
|
||||||
&& let Err(e) = db::drafts::delete_draft(&conn, draft_id)
|
&& let Err(e) = db::drafts::delete_draft(&conn, draft_id)
|
||||||
{
|
{
|
||||||
tracing::warn!(error = %e, "Failed to delete draft after send");
|
tracing::warn!(error = %e, "Failed to delete draft after send");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the attachment directory from disk.
|
|
||||||
let att_dir = Path::new(data_dir)
|
let att_dir = Path::new(data_dir)
|
||||||
.join(user_hash)
|
.join(user_hash)
|
||||||
.join("attachments")
|
.join("attachments")
|
||||||
@@ -581,7 +553,6 @@ fn build_pgp_send_config(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include the sender's public key when available so the Sent copy can be decrypted later.
|
|
||||||
if let Some(key) = db::pgp::find_key_internal_by_email(conn, sender_email)
|
if let Some(key) = db::pgp::find_key_internal_by_email(conn, sender_email)
|
||||||
.map_err(AppError::InternalError)?
|
.map_err(AppError::InternalError)?
|
||||||
{
|
{
|
||||||
@@ -591,7 +562,7 @@ fn build_pgp_send_config(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = (data_dir, user_hash); // kept in the signature for future key-agent storage without API churn
|
let _ = (data_dir, user_hash);
|
||||||
Ok(Some(PgpSendConfig {
|
Ok(Some(PgpSendConfig {
|
||||||
mode,
|
mode,
|
||||||
inline,
|
inline,
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ async fn connect(
|
|||||||
.map_err(map_sieve_error)
|
.map_err(map_sieve_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/sieve/status
|
|
||||||
pub async fn status(
|
pub async fn status(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -122,7 +121,6 @@ pub async fn status(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/sieve/scripts
|
|
||||||
pub async fn list_scripts(
|
pub async fn list_scripts(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -133,7 +131,6 @@ pub async fn list_scripts(
|
|||||||
Ok(Json(ScriptsResponse { scripts }).into_response())
|
Ok(Json(ScriptsResponse { scripts }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/sieve/scripts/:name
|
|
||||||
pub async fn get_script(
|
pub async fn get_script(
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
@@ -145,7 +142,6 @@ pub async fn get_script(
|
|||||||
Ok(Json(ScriptResponse { name, content }).into_response())
|
Ok(Json(ScriptResponse { name, content }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/sieve/check
|
|
||||||
pub async fn check_script(
|
pub async fn check_script(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -171,7 +167,6 @@ pub async fn check_script(
|
|||||||
Ok(Json(response).into_response())
|
Ok(Json(response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/sieve/scripts/:name
|
|
||||||
pub async fn put_script(
|
pub async fn put_script(
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
@@ -187,7 +182,6 @@ pub async fn put_script(
|
|||||||
Ok(Json(ActionResponse { ok: true, warning }).into_response())
|
Ok(Json(ActionResponse { ok: true, warning }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/sieve/scripts/:name/activate
|
|
||||||
pub async fn activate_script(
|
pub async fn activate_script(
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
@@ -206,7 +200,6 @@ pub async fn activate_script(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/sieve/deactivate
|
|
||||||
pub async fn deactivate_script(
|
pub async fn deactivate_script(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -224,7 +217,6 @@ pub async fn deactivate_script(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE /api/sieve/scripts/:name
|
|
||||||
pub async fn delete_script(
|
pub async fn delete_script(
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
@@ -240,7 +232,6 @@ pub async fn delete_script(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/sieve/scripts/:name/rename
|
|
||||||
pub async fn rename_script(
|
pub async fn rename_script(
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ use crate::error::AppError;
|
|||||||
use crate::imap::client::{ImapClient, ImapCredentials};
|
use crate::imap::client::{ImapClient, ImapCredentials};
|
||||||
use crate::routes::messages::types::{MessageSummary, default_per_page};
|
use crate::routes::messages::types::{MessageSummary, default_per_page};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Request / response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateTagBody {
|
pub struct CreateTagBody {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -82,11 +78,6 @@ pub struct ReconcileTagsResponse {
|
|||||||
pub skipped_folders: usize,
|
pub skipped_folders: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Build a `MessageSummary` from a cached message and its tags.
|
|
||||||
fn build_summary(m: db::messages::CachedMessage, tags: Vec<MessageTag>) -> MessageSummary {
|
fn build_summary(m: db::messages::CachedMessage, tags: Vec<MessageTag>) -> MessageSummary {
|
||||||
MessageSummary {
|
MessageSummary {
|
||||||
uid: m.uid,
|
uid: m.uid,
|
||||||
@@ -167,11 +158,6 @@ fn validate_tag_color(color: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `GET /api/tags`
|
|
||||||
pub async fn list_tags_handler(
|
pub async fn list_tags_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -185,7 +171,6 @@ pub async fn list_tags_handler(
|
|||||||
Ok(Json(ListTagsResponse { tags }).into_response())
|
Ok(Json(ListTagsResponse { tags }).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/tags`
|
|
||||||
pub async fn create_tag_handler(
|
pub async fn create_tag_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -209,7 +194,6 @@ pub async fn create_tag_handler(
|
|||||||
Ok(Json(serde_json::json!({ "id": id, "name": name, "color": color })).into_response())
|
Ok(Json(serde_json::json!({ "id": id, "name": name, "color": color })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `PUT /api/tags/{id}`
|
|
||||||
pub async fn update_tag_handler(
|
pub async fn update_tag_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -238,15 +222,12 @@ pub async fn update_tag_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/tags/{id}`
|
|
||||||
pub async fn delete_tag_handler(
|
pub async fn delete_tag_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
// Phase 1: read everything needed from SQLite, then drop the connection
|
|
||||||
// before the first network await so the Axum handler future stays Send.
|
|
||||||
let (tag, refs) = {
|
let (tag, refs) = {
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
@@ -261,7 +242,6 @@ pub async fn delete_tag_handler(
|
|||||||
(tag, refs)
|
(tag, refs)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Phase 2: best-effort remote cleanup while the tag still exists locally.
|
|
||||||
let mut synced = 0usize;
|
let mut synced = 0usize;
|
||||||
for (uid, folder) in &refs {
|
for (uid, folder) in &refs {
|
||||||
if sync_keyword_to_imap(
|
if sync_keyword_to_imap(
|
||||||
@@ -278,7 +258,6 @@ pub async fn delete_tag_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: delete the local tag after the async work has finished.
|
|
||||||
let deleted = {
|
let deleted = {
|
||||||
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
@@ -297,7 +276,6 @@ pub async fn delete_tag_handler(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/tags/{id}/messages`
|
|
||||||
pub async fn tag_message_handler(
|
pub async fn tag_message_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -333,7 +311,6 @@ pub async fn tag_message_handler(
|
|||||||
Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response())
|
Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/tags/{id}/messages/{folder}/{uid}`
|
|
||||||
pub async fn untag_message_handler(
|
pub async fn untag_message_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -376,7 +353,6 @@ pub async fn untag_message_handler(
|
|||||||
Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response())
|
Ok(Json(serde_json::json!({ "status": "ok", "imap_keyword_sync_enabled": enabled, "imap_keyword_synced": synced })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/tags/{id}/messages/bulk`
|
|
||||||
pub async fn bulk_tag_handler(
|
pub async fn bulk_tag_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -430,11 +406,6 @@ pub async fn bulk_tag_handler(
|
|||||||
Ok(Json(serde_json::json!({ "status": "ok", "count": body.messages.len(), "imap_keyword_sync_enabled": enabled, "imap_keywords_synced": synced })).into_response())
|
Ok(Json(serde_json::json!({ "status": "ok", "count": body.messages.len(), "imap_keyword_sync_enabled": enabled, "imap_keywords_synced": synced })).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/tags/reconcile`
|
|
||||||
///
|
|
||||||
/// Reconcile local RustBox labels against authoritative IMAP FLAGS. Folders
|
|
||||||
/// that do not advertise persistent custom-keyword support are skipped so
|
|
||||||
/// local-only labels are never removed by accident.
|
|
||||||
pub async fn reconcile_tags_handler(
|
pub async fn reconcile_tags_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -521,7 +492,6 @@ pub async fn reconcile_tags_handler(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/tags/{id}/messages`
|
|
||||||
pub async fn list_tag_messages_handler(
|
pub async fn list_tag_messages_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -536,7 +506,6 @@ pub async fn list_tag_messages_handler(
|
|||||||
let total_count = db::tags::count_messages_by_tag(&conn, &id)
|
let total_count = db::tags::count_messages_by_tag(&conn, &id)
|
||||||
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
|
||||||
|
|
||||||
// Batch-fetch tags for returned messages
|
|
||||||
let message_refs: Vec<(u32, &str)> = messages
|
let message_refs: Vec<(u32, &str)> = messages
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| (m.uid, m.folder.as_str()))
|
.map(|m| (m.uid, m.folder.as_str()))
|
||||||
@@ -563,7 +532,6 @@ pub async fn list_tag_messages_handler(
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/messages/{folder}/{uid}/tags`
|
|
||||||
pub async fn get_message_tags_handler(
|
pub async fn get_message_tags_handler(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
|
|||||||
+6
-67
@@ -17,7 +17,6 @@ use crate::imap::client::{
|
|||||||
use crate::smtp::client::SmtpError;
|
use crate::smtp::client::SmtpError;
|
||||||
use crate::smtp::client::mock::MockSmtpClient;
|
use crate::smtp::client::mock::MockSmtpClient;
|
||||||
|
|
||||||
/// Helper: create a test AppConfig with the given static dir.
|
|
||||||
fn test_config(_static_dir: &str) -> Arc<AppConfig> {
|
fn test_config(_static_dir: &str) -> Arc<AppConfig> {
|
||||||
Arc::new(AppConfig {
|
Arc::new(AppConfig {
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
@@ -42,7 +41,6 @@ fn test_config(_static_dir: &str) -> Arc<AppConfig> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a test AppConfig with IMAP host configured and a custom data dir.
|
|
||||||
fn test_config_with_imap(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
|
fn test_config_with_imap(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
|
||||||
Arc::new(AppConfig {
|
Arc::new(AppConfig {
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
@@ -67,7 +65,6 @@ fn test_config_with_imap(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a test AppConfig with IMAP + SMTP hosts configured.
|
|
||||||
fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
|
fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
|
||||||
Arc::new(AppConfig {
|
Arc::new(AppConfig {
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
@@ -92,22 +89,18 @@ fn test_config_with_smtp(_static_dir: &str, data_dir: &str) -> Arc<AppConfig> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a fresh SessionStore for tests.
|
|
||||||
fn test_store() -> Arc<SessionStore> {
|
fn test_store() -> Arc<SessionStore> {
|
||||||
Arc::new(SessionStore::new(Duration::from_secs(3600)))
|
Arc::new(SessionStore::new(Duration::from_secs(3600)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a default mock IMAP client.
|
|
||||||
fn test_imap_client() -> Arc<dyn ImapClient> {
|
fn test_imap_client() -> Arc<dyn ImapClient> {
|
||||||
Arc::new(MockImapClient::new())
|
Arc::new(MockImapClient::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a default mock SMTP client.
|
|
||||||
fn test_smtp_client() -> Arc<dyn SmtpClient> {
|
fn test_smtp_client() -> Arc<dyn SmtpClient> {
|
||||||
Arc::new(MockSmtpClient::new())
|
Arc::new(MockSmtpClient::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a temporary static directory with an index.html.
|
|
||||||
fn setup_static_dir() -> TempDir {
|
fn setup_static_dir() -> TempDir {
|
||||||
let dir = TempDir::new().expect("should create temp dir");
|
let dir = TempDir::new().expect("should create temp dir");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -118,19 +111,16 @@ fn setup_static_dir() -> TempDir {
|
|||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a test SearchEngine backed by the given data directory.
|
|
||||||
fn test_search_engine(data_dir: &str) -> Arc<crate::search::engine::SearchEngine> {
|
fn test_search_engine(data_dir: &str) -> Arc<crate::search::engine::SearchEngine> {
|
||||||
Arc::new(crate::search::engine::SearchEngine::new(
|
Arc::new(crate::search::engine::SearchEngine::new(
|
||||||
std::path::PathBuf::from(data_dir),
|
std::path::PathBuf::from(data_dir),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a test EventBus.
|
|
||||||
fn test_event_bus() -> Arc<crate::realtime::events::EventBus> {
|
fn test_event_bus() -> Arc<crate::realtime::events::EventBus> {
|
||||||
Arc::new(crate::realtime::events::EventBus::new())
|
Arc::new(crate::realtime::events::EventBus::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a test IdleManager.
|
|
||||||
fn test_idle_manager() -> Arc<crate::realtime::idle::IdleManager> {
|
fn test_idle_manager() -> Arc<crate::realtime::idle::IdleManager> {
|
||||||
Arc::new(crate::realtime::idle::IdleManager::new())
|
Arc::new(crate::realtime::idle::IdleManager::new())
|
||||||
}
|
}
|
||||||
@@ -159,8 +149,6 @@ fn extract_ip(extractor: &ProxyAwareIpExtractor, peer: &str, forwarded: Option<&
|
|||||||
extractor.extract(&request).unwrap()
|
extractor.extract(&request).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: create a multi-account session for testing protected routes.
|
|
||||||
/// Returns (browser_id, account_id, token) for use in request headers.
|
|
||||||
fn setup_test_account(store: &SessionStore, email: &str) -> (String, String, String) {
|
fn setup_test_account(store: &SessionStore, email: &str) -> (String, String, String) {
|
||||||
let browser_id = store.create_browser();
|
let browser_id = store.create_browser();
|
||||||
let (token, account_id) = store.add_account_to_browser(
|
let (token, account_id) = store.add_account_to_browser(
|
||||||
@@ -178,7 +166,6 @@ fn setup_test_account(store: &SessionStore, email: &str) -> (String, String, Str
|
|||||||
(browser_id, account_id, token)
|
(browser_id, account_id, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: build auth headers for multi-account requests.
|
|
||||||
fn auth_headers(browser_id: &str, account_id: &str, token: &str) -> Vec<(&'static str, String)> {
|
fn auth_headers(browser_id: &str, account_id: &str, token: &str) -> Vec<(&'static str, String)> {
|
||||||
vec![
|
vec![
|
||||||
(
|
(
|
||||||
@@ -189,16 +176,10 @@ fn auth_headers(browser_id: &str, account_id: &str, token: &str) -> Vec<(&'stati
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: provision a user database so that route handlers can open it.
|
|
||||||
/// Migrations are applied automatically by `open_user_db`.
|
|
||||||
fn provision_user_db(data_dir: &str, user_hash: &str) {
|
fn provision_user_db(data_dir: &str, user_hash: &str) {
|
||||||
let _conn = crate::db::pool::open_user_db(data_dir, user_hash).unwrap();
|
let _conn = crate::db::pool::open_user_db(data_dir, user_hash).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Proxy-aware IP extraction tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn trusted_ipv4_proxy_uses_forwarded_client_ip() {
|
fn trusted_ipv4_proxy_uses_forwarded_client_ip() {
|
||||||
let extractor = test_proxy_extractor(Some("10.0.0.0/8"));
|
let extractor = test_proxy_extractor(Some("10.0.0.0/8"));
|
||||||
@@ -264,10 +245,6 @@ fn malformed_forwarded_header_falls_back_to_peer_ip() {
|
|||||||
assert_eq!(client_ip, "10.0.0.10".parse::<IpAddr>().unwrap());
|
assert_eq!(client_ip, "10.0.0.10".parse::<IpAddr>().unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Existing tests (updated to pass imap_client)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn api_health_works_with_static_fallback() {
|
async fn api_health_works_with_static_fallback() {
|
||||||
let dir = setup_static_dir();
|
let dir = setup_static_dir();
|
||||||
@@ -536,7 +513,7 @@ async fn login_unreachable_imap_returns_503() {
|
|||||||
let dir = setup_static_dir();
|
let dir = setup_static_dir();
|
||||||
let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone();
|
let mut cfg = (*test_config(dir.path().to_str().unwrap())).clone();
|
||||||
cfg.imap_host = Some("127.0.0.1".to_string());
|
cfg.imap_host = Some("127.0.0.1".to_string());
|
||||||
cfg.imap_port = 19999; // Nothing listening here
|
cfg.imap_port = 19999;
|
||||||
cfg.tls_enabled = false;
|
cfg.tls_enabled = false;
|
||||||
let config = Arc::new(cfg);
|
let config = Arc::new(cfg);
|
||||||
let store = test_store();
|
let store = test_store();
|
||||||
@@ -694,7 +671,6 @@ async fn logout_with_valid_session_returns_200() {
|
|||||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(json["status"], "logged_out");
|
assert_eq!(json["status"], "logged_out");
|
||||||
|
|
||||||
// Session should be removed from the store.
|
|
||||||
assert!(store.get(&token).is_none());
|
assert!(store.get(&token).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,7 +706,6 @@ async fn base_path_mounts_protected_data_routes_and_scopes_logout_cookies() {
|
|||||||
test_idle_manager(),
|
test_idle_manager(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// This route is outside /api/auth, where browsers default empty cookie paths.
|
|
||||||
let mut request = Request::builder().uri(format!("{prefix}/api/folders"));
|
let mut request = Request::builder().uri(format!("{prefix}/api/folders"));
|
||||||
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
|
for (name, value) in auth_headers(&browser_id, &account_id, &token) {
|
||||||
request = request.header(name, value);
|
request = request.header(name, value);
|
||||||
@@ -795,15 +770,11 @@ async fn logout_clears_cookie() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let set_cookie = response.headers().get_all("set-cookie");
|
let set_cookie = response.headers().get_all("set-cookie");
|
||||||
// Should have clearing cookies for both browser and account session
|
|
||||||
let cookie_str = format!("{:?}", set_cookie);
|
let cookie_str = format!("{:?}", set_cookie);
|
||||||
assert!(cookie_str.contains("rustbox_browser=;") || cookie_str.contains("Max-Age=0"));
|
assert!(cookie_str.contains("rustbox_browser=;") || cookie_str.contains("Max-Age=0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// New tests for folders and messages routes
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_folders_returns_200_with_folder_list() {
|
async fn get_folders_returns_200_with_folder_list() {
|
||||||
let static_dir = setup_static_dir();
|
let static_dir = setup_static_dir();
|
||||||
@@ -815,7 +786,6 @@ async fn get_folders_returns_200_with_folder_list() {
|
|||||||
let store = test_store();
|
let store = test_store();
|
||||||
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
|
let (browser_id, account_id, token) = setup_test_account(&store, "alice@example.com");
|
||||||
|
|
||||||
// Provision user database.
|
|
||||||
provision_user_db(
|
provision_user_db(
|
||||||
data_dir.path().to_str().unwrap(),
|
data_dir.path().to_str().unwrap(),
|
||||||
&crate::auth::user_data::hash_email("alice@example.com"),
|
&crate::auth::user_data::hash_email("alice@example.com"),
|
||||||
@@ -1038,7 +1008,6 @@ async fn get_message_returns_sanitized_html() {
|
|||||||
&crate::auth::user_data::hash_email("alice@example.com"),
|
&crate::auth::user_data::hash_email("alice@example.com"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// First, we need the message header in cache (fetch_headers first).
|
|
||||||
let mock = MockImapClient::new()
|
let mock = MockImapClient::new()
|
||||||
.with_headers(vec![ImapMessageHeader {
|
.with_headers(vec![ImapMessageHeader {
|
||||||
uid: 42,
|
uid: 42,
|
||||||
@@ -1079,7 +1048,6 @@ async fn get_message_returns_sanitized_html() {
|
|||||||
test_idle_manager(),
|
test_idle_manager(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// First, populate the message cache by listing messages.
|
|
||||||
let mut req1 = Request::builder()
|
let mut req1 = Request::builder()
|
||||||
.uri("/api/folders/INBOX/messages")
|
.uri("/api/folders/INBOX/messages")
|
||||||
.header("x-requested-with", "XMLHttpRequest");
|
.header("x-requested-with", "XMLHttpRequest");
|
||||||
@@ -1092,7 +1060,6 @@ async fn get_message_returns_sanitized_html() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
// Now get the full message.
|
|
||||||
let app2 = create_router(
|
let app2 = create_router(
|
||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
@@ -1121,19 +1088,15 @@ async fn get_message_returns_sanitized_html() {
|
|||||||
assert_eq!(json["uid"], 42);
|
assert_eq!(json["uid"], 42);
|
||||||
assert_eq!(json["subject"], "Test Subject");
|
assert_eq!(json["subject"], "Test Subject");
|
||||||
|
|
||||||
// Script tag is preserved (frontend handles security via iframe sandbox).
|
|
||||||
let html = json["html"].as_str().unwrap();
|
let html = json["html"].as_str().unwrap();
|
||||||
assert!(html.contains("script"));
|
assert!(html.contains("script"));
|
||||||
assert!(html.contains("<b>bold</b>"));
|
assert!(html.contains("<b>bold</b>"));
|
||||||
assert!(html.contains("<p>Hello</p>"));
|
assert!(html.contains("<p>Hello</p>"));
|
||||||
|
|
||||||
// Plain text should be preserved.
|
|
||||||
assert_eq!(json["text"], "Hello plain text");
|
assert_eq!(json["text"], "Hello plain text");
|
||||||
|
|
||||||
// Flags should be an array.
|
|
||||||
assert!(json["flags"].is_array());
|
assert!(json["flags"].is_array());
|
||||||
|
|
||||||
// to_addresses should be an array.
|
|
||||||
assert!(json["to_addresses"].is_array());
|
assert!(json["to_addresses"].is_array());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,7 +1114,6 @@ async fn update_flags_returns_200() {
|
|||||||
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
||||||
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
||||||
|
|
||||||
// Seed a message in the cache.
|
|
||||||
let conn =
|
let conn =
|
||||||
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
||||||
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
|
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||||
@@ -1210,9 +1172,6 @@ async fn update_flags_returns_200() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_flags_rejects_invalid_flag_characters() {
|
async fn update_flags_rejects_invalid_flag_characters() {
|
||||||
// Flag values must be IMAP atom characters (RFC 3501 §9). A value
|
|
||||||
// containing a space — e.g. two flags accidentally joined into one
|
|
||||||
// string — must be rejected with 400 before reaching the IMAP client.
|
|
||||||
let static_dir = setup_static_dir();
|
let static_dir = setup_static_dir();
|
||||||
let data_dir = TempDir::new().unwrap();
|
let data_dir = TempDir::new().unwrap();
|
||||||
let config = test_config_with_imap(
|
let config = test_config_with_imap(
|
||||||
@@ -1251,8 +1210,7 @@ async fn update_flags_rejects_invalid_flag_characters() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
// BAD_REQUEST confirms rejection at the validation layer — the IMAP
|
|
||||||
// error path returns SERVICE_UNAVAILABLE (503), not 400.
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(json["error"]["code"], "BAD_REQUEST");
|
assert_eq!(json["error"]["code"], "BAD_REQUEST");
|
||||||
@@ -1314,7 +1272,6 @@ async fn move_message_returns_200() {
|
|||||||
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
||||||
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
||||||
|
|
||||||
// Seed a message in the cache.
|
|
||||||
let conn =
|
let conn =
|
||||||
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
||||||
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
|
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||||
@@ -1485,7 +1442,6 @@ async fn delete_message_returns_200() {
|
|||||||
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
||||||
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
||||||
|
|
||||||
// Seed a message in the cache.
|
|
||||||
let conn =
|
let conn =
|
||||||
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
||||||
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
|
crate::db::folders::upsert_folder(&conn, "INBOX", None, None, "", true, 0, 0, 0, 0).unwrap();
|
||||||
@@ -1535,10 +1491,6 @@ async fn delete_message_returns_200() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Attachment download tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn download_attachment_returns_binary_with_correct_headers() {
|
async fn download_attachment_returns_binary_with_correct_headers() {
|
||||||
let static_dir = setup_static_dir();
|
let static_dir = setup_static_dir();
|
||||||
@@ -1588,7 +1540,6 @@ async fn download_attachment_returns_binary_with_correct_headers() {
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
// Verify content-type header.
|
|
||||||
let ct = response
|
let ct = response
|
||||||
.headers()
|
.headers()
|
||||||
.get("content-type")
|
.get("content-type")
|
||||||
@@ -1597,7 +1548,6 @@ async fn download_attachment_returns_binary_with_correct_headers() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(ct, "application/pdf");
|
assert_eq!(ct, "application/pdf");
|
||||||
|
|
||||||
// Verify content-disposition header.
|
|
||||||
let cd = response
|
let cd = response
|
||||||
.headers()
|
.headers()
|
||||||
.get("content-disposition")
|
.get("content-disposition")
|
||||||
@@ -1607,7 +1557,6 @@ async fn download_attachment_returns_binary_with_correct_headers() {
|
|||||||
assert!(cd.contains("inline"));
|
assert!(cd.contains("inline"));
|
||||||
assert!(cd.contains("document.pdf"));
|
assert!(cd.contains("document.pdf"));
|
||||||
|
|
||||||
// Verify body bytes match the attachment data.
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
assert_eq!(body.as_ref(), &attachment_data);
|
assert_eq!(body.as_ref(), &attachment_data);
|
||||||
}
|
}
|
||||||
@@ -1618,9 +1567,6 @@ async fn download_attachment_strips_control_chars_from_filename() {
|
|||||||
("doc\r\nument.pdf", "document.pdf"),
|
("doc\r\nument.pdf", "document.pdf"),
|
||||||
("\r\n\0\u{7f}", "attachment_0"),
|
("\r\n\0\u{7f}", "attachment_0"),
|
||||||
] {
|
] {
|
||||||
// MIME decoders can surface control characters in attachment filenames
|
|
||||||
// via quoted-pair escapes. Control characters must be stripped before
|
|
||||||
// the filename is used in the Content-Disposition header.
|
|
||||||
let static_dir = setup_static_dir();
|
let static_dir = setup_static_dir();
|
||||||
let data_dir = TempDir::new().unwrap();
|
let data_dir = TempDir::new().unwrap();
|
||||||
let config = test_config_with_imap(
|
let config = test_config_with_imap(
|
||||||
@@ -1672,7 +1618,7 @@ async fn download_attachment_strips_control_chars_from_filename() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Control characters stripped; the rest of the filename is intact.
|
|
||||||
assert!(cd.contains(expected_filename), "got: {cd}");
|
assert!(cd.contains(expected_filename), "got: {cd}");
|
||||||
assert!(!cd.contains('\r'));
|
assert!(!cd.contains('\r'));
|
||||||
assert!(!cd.contains('\n'));
|
assert!(!cd.contains('\n'));
|
||||||
@@ -1777,10 +1723,6 @@ async fn download_attachment_returns_400_for_non_numeric_id() {
|
|||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Send message endpoint tests
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_returns_200_on_success() {
|
async fn send_returns_200_on_success() {
|
||||||
let static_dir = setup_static_dir();
|
let static_dir = setup_static_dir();
|
||||||
@@ -2150,7 +2092,7 @@ async fn quota_without_server_limit_uses_cached_sizes() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(conn);
|
drop(conn);
|
||||||
// The mock's IMAP size fallback returns zero; only SQLite has 4096.
|
|
||||||
let app = create_router(
|
let app = create_router(
|
||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
@@ -2178,8 +2120,6 @@ async fn export_contact_strips_control_chars_from_filename() {
|
|||||||
("Bob\nSmith", "BobSmith.vcf"),
|
("Bob\nSmith", "BobSmith.vcf"),
|
||||||
("\r\n\0\u{7f}", "contact.vcf"),
|
("\r\n\0\u{7f}", "contact.vcf"),
|
||||||
] {
|
] {
|
||||||
// A contact name containing control characters must have them stripped
|
|
||||||
// before the name is used in the Content-Disposition header.
|
|
||||||
let static_dir = setup_static_dir();
|
let static_dir = setup_static_dir();
|
||||||
let data_dir = TempDir::new().unwrap();
|
let data_dir = TempDir::new().unwrap();
|
||||||
let config = test_config_with_imap(
|
let config = test_config_with_imap(
|
||||||
@@ -2192,7 +2132,6 @@ async fn export_contact_strips_control_chars_from_filename() {
|
|||||||
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
let user_hash = crate::auth::user_data::hash_email("alice@example.com");
|
||||||
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
provision_user_db(data_dir.path().to_str().unwrap(), &user_hash);
|
||||||
|
|
||||||
// Insert a contact whose name contains a newline.
|
|
||||||
let conn =
|
let conn =
|
||||||
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
crate::db::pool::open_user_db(data_dir.path().to_str().unwrap(), &user_hash).unwrap();
|
||||||
let contact = crate::db::contacts::Contact {
|
let contact = crate::db::contacts::Contact {
|
||||||
@@ -2246,7 +2185,7 @@ async fn export_contact_strips_control_chars_from_filename() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Control character stripped; the rest of the name is intact.
|
|
||||||
assert!(cd.contains(expected_filename), "got: {cd}");
|
assert!(cd.contains(expected_filename), "got: {cd}");
|
||||||
assert!(!cd.contains('\n'));
|
assert!(!cd.contains('\n'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
|||||||
const MAX_RESPONSE_BYTES: usize = 8192;
|
const MAX_RESPONSE_BYTES: usize = 8192;
|
||||||
const ONE_CLICK_BODY: &str = "List-Unsubscribe=One-Click";
|
const ONE_CLICK_BODY: &str = "List-Unsubscribe=One-Click";
|
||||||
|
|
||||||
/// `POST /api/messages/{folder}/{uid}/unsubscribe` — perform RFC 8058 one-click
|
|
||||||
/// unsubscribe when the message explicitly advertises List-Unsubscribe-Post.
|
|
||||||
pub async fn unsubscribe_message(
|
pub async fn unsubscribe_message(
|
||||||
Extension(session): Extension<SessionState>,
|
Extension(session): Extension<SessionState>,
|
||||||
Extension(config): Extension<Arc<AppConfig>>,
|
Extension(config): Extension<Arc<AppConfig>>,
|
||||||
@@ -230,7 +228,7 @@ async fn post_one_click_to_address(
|
|||||||
format!("{host}:{port}")
|
format!("{host}:{port}")
|
||||||
};
|
};
|
||||||
let request = format!(
|
let request = format!(
|
||||||
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.4\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}",
|
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nUser-Agent: RustBox/0.0.5\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ONE_CLICK_BODY}",
|
||||||
ONE_CLICK_BODY.len()
|
ONE_CLICK_BODY.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+5
-48
@@ -11,7 +11,6 @@ use tantivy::schema::{
|
|||||||
use tantivy::snippet::SnippetGenerator;
|
use tantivy::snippet::SnippetGenerator;
|
||||||
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc};
|
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc};
|
||||||
|
|
||||||
/// A message ready to be indexed into the search engine.
|
|
||||||
pub struct IndexableMessage {
|
pub struct IndexableMessage {
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
pub folder: String,
|
pub folder: String,
|
||||||
@@ -24,7 +23,6 @@ pub struct IndexableMessage {
|
|||||||
pub has_attachments: bool,
|
pub has_attachments: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single search result returned from a query.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct SearchResult {
|
pub struct SearchResult {
|
||||||
pub uid: u32,
|
pub uid: u32,
|
||||||
@@ -33,7 +31,6 @@ pub struct SearchResult {
|
|||||||
pub snippet: String,
|
pub snippet: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for a search query, with optional filters.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct SearchQuery {
|
pub struct SearchQuery {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
@@ -48,12 +45,10 @@ pub struct SearchQuery {
|
|||||||
pub offset: usize,
|
pub offset: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level search engine that manages per-user indices.
|
|
||||||
pub struct SearchEngine {
|
pub struct SearchEngine {
|
||||||
base_dir: PathBuf,
|
base_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Named field handles for convenient access within the schema.
|
|
||||||
struct SchemaFields {
|
struct SchemaFields {
|
||||||
uid: Field,
|
uid: Field,
|
||||||
folder: Field,
|
folder: Field,
|
||||||
@@ -66,7 +61,6 @@ struct SchemaFields {
|
|||||||
has_attachments: Field,
|
has_attachments: Field,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A per-user Tantivy index with reader and schema.
|
|
||||||
pub struct UserIndex {
|
pub struct UserIndex {
|
||||||
index: Index,
|
index: Index,
|
||||||
reader: IndexReader,
|
reader: IndexReader,
|
||||||
@@ -75,7 +69,6 @@ pub struct UserIndex {
|
|||||||
fields: SchemaFields,
|
fields: SchemaFields,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the shared schema used by all user indices.
|
|
||||||
fn build_schema() -> (Schema, SchemaFields) {
|
fn build_schema() -> (Schema, SchemaFields) {
|
||||||
let mut builder = Schema::builder();
|
let mut builder = Schema::builder();
|
||||||
|
|
||||||
@@ -114,12 +107,10 @@ fn build_schema() -> (Schema, SchemaFields) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SearchEngine {
|
impl SearchEngine {
|
||||||
/// Create a new search engine with the given base directory.
|
|
||||||
pub fn new(base_dir: PathBuf) -> Self {
|
pub fn new(base_dir: PathBuf) -> Self {
|
||||||
Self { base_dir }
|
Self { base_dir }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open or create the Tantivy index for a specific user.
|
|
||||||
pub fn open_user_index(&self, user_hash: &str) -> Result<UserIndex, String> {
|
pub fn open_user_index(&self, user_hash: &str) -> Result<UserIndex, String> {
|
||||||
let index_dir = self.base_dir.join(user_hash).join("tantivy");
|
let index_dir = self.base_dir.join(user_hash).join("tantivy");
|
||||||
std::fs::create_dir_all(&index_dir)
|
std::fs::create_dir_all(&index_dir)
|
||||||
@@ -151,8 +142,6 @@ impl SearchEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserIndex {
|
impl UserIndex {
|
||||||
/// Returns `true` if the given folder should be excluded from search indexing
|
|
||||||
/// (e.g. Spam, Junk, Trash).
|
|
||||||
pub fn is_excluded_folder(folder: &str) -> bool {
|
pub fn is_excluded_folder(folder: &str) -> bool {
|
||||||
let lower = folder.to_lowercase();
|
let lower = folder.to_lowercase();
|
||||||
lower == "trash"
|
lower == "trash"
|
||||||
@@ -163,7 +152,6 @@ impl UserIndex {
|
|||||||
|| lower.ends_with("/spam")
|
|| lower.ends_with("/spam")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index a single message. Uses delete-before-insert for upsert semantics.
|
|
||||||
pub fn index_message(&self, msg: &IndexableMessage) -> Result<(), String> {
|
pub fn index_message(&self, msg: &IndexableMessage) -> Result<(), String> {
|
||||||
let mut writer: IndexWriter = self
|
let mut writer: IndexWriter = self
|
||||||
.index
|
.index
|
||||||
@@ -188,14 +176,10 @@ impl UserIndex {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the number of indexed documents currently visible to the reader.
|
|
||||||
pub fn document_count(&self) -> usize {
|
pub fn document_count(&self) -> usize {
|
||||||
self.reader.searcher().num_docs() as usize
|
self.reader.searcher().num_docs() as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the complete user index with the supplied message set.
|
|
||||||
/// Used by the diagnostics/recovery workflow after index corruption or
|
|
||||||
/// schema changes.
|
|
||||||
pub fn rebuild_from_messages(&self, messages: &[IndexableMessage]) -> Result<usize, String> {
|
pub fn rebuild_from_messages(&self, messages: &[IndexableMessage]) -> Result<usize, String> {
|
||||||
let mut writer: IndexWriter = self
|
let mut writer: IndexWriter = self
|
||||||
.index
|
.index
|
||||||
@@ -225,7 +209,6 @@ impl UserIndex {
|
|||||||
Ok(self.document_count())
|
Ok(self.document_count())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index multiple messages in a single batch. Uses delete-before-insert for each.
|
|
||||||
pub fn index_messages_batch(&self, messages: &[IndexableMessage]) -> Result<(), String> {
|
pub fn index_messages_batch(&self, messages: &[IndexableMessage]) -> Result<(), String> {
|
||||||
let mut writer: IndexWriter = self
|
let mut writer: IndexWriter = self
|
||||||
.index
|
.index
|
||||||
@@ -251,7 +234,6 @@ impl UserIndex {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a message from the index by uid and folder.
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn delete_message(&self, uid: u32, folder: &str) -> Result<(), String> {
|
pub fn delete_message(&self, uid: u32, folder: &str) -> Result<(), String> {
|
||||||
let mut writer: IndexWriter = self
|
let mut writer: IndexWriter = self
|
||||||
@@ -272,15 +254,11 @@ impl UserIndex {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search the index with a text query and optional filters.
|
|
||||||
/// Returns matching results and total count.
|
|
||||||
pub fn search(&self, query: &SearchQuery) -> Result<(Vec<SearchResult>, usize), String> {
|
pub fn search(&self, query: &SearchQuery) -> Result<(Vec<SearchResult>, usize), String> {
|
||||||
let searcher = self.reader.searcher();
|
let searcher = self.reader.searcher();
|
||||||
|
|
||||||
// Collect all sub-queries into a BooleanQuery with Must clauses.
|
|
||||||
let mut clauses: Vec<(Occur, Box<dyn tantivy::query::Query>)> = Vec::new();
|
let mut clauses: Vec<(Occur, Box<dyn tantivy::query::Query>)> = Vec::new();
|
||||||
|
|
||||||
// Build the full-text query across subject, body_text, from_name, to_addresses.
|
|
||||||
if !query.text.is_empty() {
|
if !query.text.is_empty() {
|
||||||
let query_parser = QueryParser::for_index(
|
let query_parser = QueryParser::for_index(
|
||||||
&self.index,
|
&self.index,
|
||||||
@@ -299,7 +277,6 @@ impl UserIndex {
|
|||||||
clauses.push((Occur::Must, text_query));
|
clauses.push((Occur::Must, text_query));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional subject-only search (searches only the subject field).
|
|
||||||
if let Some(ref subject_text) = query.subject_only {
|
if let Some(ref subject_text) = query.subject_only {
|
||||||
let subject_parser = QueryParser::for_index(&self.index, vec![self.fields.subject]);
|
let subject_parser = QueryParser::for_index(&self.index, vec![self.fields.subject]);
|
||||||
let subject_query = subject_parser
|
let subject_query = subject_parser
|
||||||
@@ -308,20 +285,16 @@ impl UserIndex {
|
|||||||
clauses.push((Occur::Must, subject_query));
|
clauses.push((Occur::Must, subject_query));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no text clauses were added, use a match-all query so filters still work.
|
|
||||||
if clauses.is_empty() {
|
if clauses.is_empty() {
|
||||||
clauses.push((Occur::Must, Box::new(tantivy::query::AllQuery)));
|
clauses.push((Occur::Must, Box::new(tantivy::query::AllQuery)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional folder filter (STRING field -> TermQuery).
|
|
||||||
if let Some(ref folder) = query.folder {
|
if let Some(ref folder) = query.folder {
|
||||||
let term = Term::from_field_text(self.fields.folder, folder);
|
let term = Term::from_field_text(self.fields.folder, folder);
|
||||||
let folder_query = TermQuery::new(term, IndexRecordOption::Basic);
|
let folder_query = TermQuery::new(term, IndexRecordOption::Basic);
|
||||||
clauses.push((Occur::Must, Box::new(folder_query)));
|
clauses.push((Occur::Must, Box::new(folder_query)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional from filter: if value contains '@', exact match on from_address;
|
|
||||||
// otherwise, text search on from_name.
|
|
||||||
if let Some(ref from) = query.from {
|
if let Some(ref from) = query.from {
|
||||||
if from.contains('@') {
|
if from.contains('@') {
|
||||||
let term = Term::from_field_text(self.fields.from_address, from);
|
let term = Term::from_field_text(self.fields.from_address, from);
|
||||||
@@ -336,7 +309,6 @@ impl UserIndex {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional to filter (TEXT field -> text search on to_addresses).
|
|
||||||
if let Some(ref to) = query.to {
|
if let Some(ref to) = query.to {
|
||||||
let to_parser = QueryParser::for_index(&self.index, vec![self.fields.to_addresses]);
|
let to_parser = QueryParser::for_index(&self.index, vec![self.fields.to_addresses]);
|
||||||
let to_query = to_parser
|
let to_query = to_parser
|
||||||
@@ -345,7 +317,6 @@ impl UserIndex {
|
|||||||
clauses.push((Occur::Must, to_query));
|
clauses.push((Occur::Must, to_query));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional date range filter.
|
|
||||||
if query.date_from.is_some() || query.date_to.is_some() {
|
if query.date_from.is_some() || query.date_to.is_some() {
|
||||||
let lower = match query.date_from {
|
let lower = match query.date_from {
|
||||||
Some(ts) => Bound::Included(ts),
|
Some(ts) => Bound::Included(ts),
|
||||||
@@ -359,7 +330,6 @@ impl UserIndex {
|
|||||||
clauses.push((Occur::Must, Box::new(range_query)));
|
clauses.push((Occur::Must, Box::new(range_query)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional has_attachment filter.
|
|
||||||
if let Some(has_att) = query.has_attachment {
|
if let Some(has_att) = query.has_attachment {
|
||||||
let val = if has_att { 1u64 } else { 0u64 };
|
let val = if has_att { 1u64 } else { 0u64 };
|
||||||
let term = Term::from_field_u64(self.fields.has_attachments, val);
|
let term = Term::from_field_u64(self.fields.has_attachments, val);
|
||||||
@@ -369,13 +339,11 @@ impl UserIndex {
|
|||||||
|
|
||||||
let combined_query = BooleanQuery::new(clauses);
|
let combined_query = BooleanQuery::new(clauses);
|
||||||
|
|
||||||
// Get total count and paginated results.
|
|
||||||
let total_limit = query.offset + query.limit;
|
let total_limit = query.offset + query.limit;
|
||||||
let (total_count, top_docs) = searcher
|
let (total_count, top_docs) = searcher
|
||||||
.search(&combined_query, &(Count, TopDocs::with_limit(total_limit)))
|
.search(&combined_query, &(Count, TopDocs::with_limit(total_limit)))
|
||||||
.map_err(|e| format!("Search failed: {e}"))?;
|
.map_err(|e| format!("Search failed: {e}"))?;
|
||||||
|
|
||||||
// Build a snippet generator for the subject field.
|
|
||||||
let mut snippet_generator =
|
let mut snippet_generator =
|
||||||
SnippetGenerator::create(&searcher, &combined_query, self.fields.subject)
|
SnippetGenerator::create(&searcher, &combined_query, self.fields.subject)
|
||||||
.map_err(|e| format!("Failed to create snippet generator: {e}"))?;
|
.map_err(|e| format!("Failed to create snippet generator: {e}"))?;
|
||||||
@@ -416,7 +384,6 @@ impl UserIndex {
|
|||||||
Ok((results, total_count))
|
Ok((results, total_count))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete existing documents that match the uid+folder combination.
|
|
||||||
fn delete_existing(&self, writer: &mut IndexWriter, uid: u32, folder: &str) {
|
fn delete_existing(&self, writer: &mut IndexWriter, uid: u32, folder: &str) {
|
||||||
let uid_term = Term::from_field_u64(self.fields.uid, uid as u64);
|
let uid_term = Term::from_field_u64(self.fields.uid, uid as u64);
|
||||||
let folder_term = Term::from_field_text(self.fields.folder, folder);
|
let folder_term = Term::from_field_text(self.fields.folder, folder);
|
||||||
@@ -432,7 +399,6 @@ impl UserIndex {
|
|||||||
let _ = writer.delete_query(Box::new(delete_query));
|
let _ = writer.delete_query(Box::new(delete_query));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a Tantivy document from an IndexableMessage.
|
|
||||||
fn build_document(&self, msg: &IndexableMessage) -> TantivyDocument {
|
fn build_document(&self, msg: &IndexableMessage) -> TantivyDocument {
|
||||||
doc!(
|
doc!(
|
||||||
self.fields.uid => msg.uid as u64,
|
self.fields.uid => msg.uid as u64,
|
||||||
@@ -453,7 +419,6 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
/// Helper: create a SearchEngine + UserIndex in a temp directory.
|
|
||||||
fn setup() -> (TempDir, UserIndex) {
|
fn setup() -> (TempDir, UserIndex) {
|
||||||
let tmp = TempDir::new().expect("Failed to create temp dir");
|
let tmp = TempDir::new().expect("Failed to create temp dir");
|
||||||
let engine = SearchEngine::new(tmp.path().to_path_buf());
|
let engine = SearchEngine::new(tmp.path().to_path_buf());
|
||||||
@@ -535,7 +500,6 @@ mod tests {
|
|||||||
|
|
||||||
idx.index_messages_batch(&[msg1, msg2]).unwrap();
|
idx.index_messages_batch(&[msg1, msg2]).unwrap();
|
||||||
|
|
||||||
// Search with folder filter for "Sent" only.
|
|
||||||
let query = SearchQuery {
|
let query = SearchQuery {
|
||||||
text: "project".to_string(),
|
text: "project".to_string(),
|
||||||
folder: Some("Sent".to_string()),
|
folder: Some("Sent".to_string()),
|
||||||
@@ -555,21 +519,20 @@ mod tests {
|
|||||||
let (_tmp, idx) = setup();
|
let (_tmp, idx) = setup();
|
||||||
|
|
||||||
let mut msg1 = make_message(1, "INBOX", "January news", "News from January.");
|
let mut msg1 = make_message(1, "INBOX", "January news", "News from January.");
|
||||||
msg1.date_epoch = 1704067200; // 2024-01-01
|
msg1.date_epoch = 1704067200;
|
||||||
|
|
||||||
let mut msg2 = make_message(2, "INBOX", "March news", "News from March.");
|
let mut msg2 = make_message(2, "INBOX", "March news", "News from March.");
|
||||||
msg2.date_epoch = 1709251200; // 2024-03-01
|
msg2.date_epoch = 1709251200;
|
||||||
|
|
||||||
let mut msg3 = make_message(3, "INBOX", "June news", "News from June.");
|
let mut msg3 = make_message(3, "INBOX", "June news", "News from June.");
|
||||||
msg3.date_epoch = 1717200000; // 2024-06-01
|
msg3.date_epoch = 1717200000;
|
||||||
|
|
||||||
idx.index_messages_batch(&[msg1, msg2, msg3]).unwrap();
|
idx.index_messages_batch(&[msg1, msg2, msg3]).unwrap();
|
||||||
|
|
||||||
// Search for "news" within February to April.
|
|
||||||
let query = SearchQuery {
|
let query = SearchQuery {
|
||||||
text: "news".to_string(),
|
text: "news".to_string(),
|
||||||
date_from: Some(1706745600), // 2024-02-01
|
date_from: Some(1706745600),
|
||||||
date_to: Some(1714521600), // 2024-05-01
|
date_to: Some(1714521600),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -589,7 +552,6 @@ mod tests {
|
|||||||
|
|
||||||
idx.index_messages_batch(&[msg1, msg2]).unwrap();
|
idx.index_messages_batch(&[msg1, msg2]).unwrap();
|
||||||
|
|
||||||
// Verify both are found.
|
|
||||||
let query = SearchQuery {
|
let query = SearchQuery {
|
||||||
text: "important".to_string(),
|
text: "important".to_string(),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
@@ -598,7 +560,6 @@ mod tests {
|
|||||||
let (_, total) = idx.search(&query).unwrap();
|
let (_, total) = idx.search(&query).unwrap();
|
||||||
assert_eq!(total, 2);
|
assert_eq!(total, 2);
|
||||||
|
|
||||||
// Delete message 1.
|
|
||||||
idx.delete_message(1, "INBOX").unwrap();
|
idx.delete_message(1, "INBOX").unwrap();
|
||||||
|
|
||||||
let (results, total) = idx.search(&query).unwrap();
|
let (results, total) = idx.search(&query).unwrap();
|
||||||
@@ -614,7 +575,6 @@ mod tests {
|
|||||||
let msg = make_message(1, "INBOX", "Test subject", "Test body text.");
|
let msg = make_message(1, "INBOX", "Test subject", "Test body text.");
|
||||||
idx.index_message(&msg).unwrap();
|
idx.index_message(&msg).unwrap();
|
||||||
|
|
||||||
// Empty text with no filters uses AllQuery — returns everything.
|
|
||||||
let query = SearchQuery {
|
let query = SearchQuery {
|
||||||
text: "".to_string(),
|
text: "".to_string(),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
@@ -643,7 +603,6 @@ mod tests {
|
|||||||
|
|
||||||
idx.index_messages_batch(&messages).unwrap();
|
idx.index_messages_batch(&messages).unwrap();
|
||||||
|
|
||||||
// Page 1: limit=2, offset=0.
|
|
||||||
let query_page1 = SearchQuery {
|
let query_page1 = SearchQuery {
|
||||||
text: "report".to_string(),
|
text: "report".to_string(),
|
||||||
limit: 2,
|
limit: 2,
|
||||||
@@ -654,7 +613,6 @@ mod tests {
|
|||||||
assert_eq!(total1, 5);
|
assert_eq!(total1, 5);
|
||||||
assert_eq!(results1.len(), 2);
|
assert_eq!(results1.len(), 2);
|
||||||
|
|
||||||
// Page 2: limit=2, offset=2.
|
|
||||||
let query_page2 = SearchQuery {
|
let query_page2 = SearchQuery {
|
||||||
text: "report".to_string(),
|
text: "report".to_string(),
|
||||||
limit: 2,
|
limit: 2,
|
||||||
@@ -665,7 +623,6 @@ mod tests {
|
|||||||
assert_eq!(total2, 5);
|
assert_eq!(total2, 5);
|
||||||
assert_eq!(results2.len(), 2);
|
assert_eq!(results2.len(), 2);
|
||||||
|
|
||||||
// Results from page 1 and page 2 should be different.
|
|
||||||
let uids1: Vec<u32> = results1.iter().map(|r| r.uid).collect();
|
let uids1: Vec<u32> = results1.iter().map(|r| r.uid).collect();
|
||||||
let uids2: Vec<u32> = results2.iter().map(|r| r.uid).collect();
|
let uids2: Vec<u32> = results2.iter().map(|r| r.uid).collect();
|
||||||
for uid in &uids2 {
|
for uid in &uids2 {
|
||||||
|
|||||||
+3
-10
@@ -19,7 +19,7 @@ pub const MAX_SCRIPT_BYTES: usize = 1024 * 1024;
|
|||||||
pub struct SieveCredentials {
|
pub struct SieveCredentials {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// `starttls`, `implicit`, or `none`.
|
|
||||||
pub tls_mode: String,
|
pub tls_mode: String,
|
||||||
pub allow_insecure_auth: bool,
|
pub allow_insecure_auth: bool,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -84,16 +84,12 @@ impl ManageSieveClient {
|
|||||||
let reply = read_response(&mut tcp, creds.timeout).await?;
|
let reply = read_response(&mut tcp, creds.timeout).await?;
|
||||||
ensure_ok(&reply)?;
|
ensure_ok(&reply)?;
|
||||||
|
|
||||||
// Do not buffer beyond the STARTTLS response. read_response() consumes
|
|
||||||
// exactly one protocol line at a time, preventing plaintext bytes from
|
|
||||||
// being replayed into the TLS stream.
|
|
||||||
let tls = async_native_tls::TlsConnector::new();
|
let tls = async_native_tls::TlsConnector::new();
|
||||||
let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp))
|
let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| SieveError::Timeout)?
|
.map_err(|_| SieveError::Timeout)?
|
||||||
.map_err(|e| SieveError::TlsFailed(e.to_string()))?;
|
.map_err(|e| SieveError::TlsFailed(e.to_string()))?;
|
||||||
|
|
||||||
// RFC 5804 requires the server to re-issue capabilities after TLS.
|
|
||||||
let greeting = read_response(&mut tls_stream, creds.timeout).await?;
|
let greeting = read_response(&mut tls_stream, creds.timeout).await?;
|
||||||
(SieveStream::Tls(tls_stream), greeting)
|
(SieveStream::Tls(tls_stream), greeting)
|
||||||
}
|
}
|
||||||
@@ -212,7 +208,6 @@ impl ManageSieveClient {
|
|||||||
return Err(SieveError::ScriptTooLarge);
|
return Err(SieveError::ScriptTooLarge);
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC 5804 recommends HAVESPACE before PUTSCRIPT for larger scripts.
|
|
||||||
let have_space = format!("HAVESPACE {} {}\r\n", quote(name)?, script.len());
|
let have_space = format!("HAVESPACE {} {}\r\n", quote(name)?, script.len());
|
||||||
let response = self.command(&have_space).await?;
|
let response = self.command(&have_space).await?;
|
||||||
ensure_ok(&response)?;
|
ensure_ok(&response)?;
|
||||||
@@ -362,7 +357,7 @@ impl ProtocolResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let text = format!("{}{}", self.final_line.before, self.final_line.after);
|
let text = format!("{}{}", self.final_line.before, self.final_line.after);
|
||||||
// Prefer a quoted human-readable string, falling back to the full status line.
|
|
||||||
if let Some(pos) = text.rfind('"') {
|
if let Some(pos) = text.rfind('"') {
|
||||||
let prefix = &text[..pos];
|
let prefix = &text[..pos];
|
||||||
if let Some(start) = prefix.rfind('"') {
|
if let Some(start) = prefix.rfind('"') {
|
||||||
@@ -436,8 +431,6 @@ async fn read_wire_line<S: AsyncRead + Unpin>(stream: &mut S) -> Result<WireLine
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
// A literal is a token inside the response line. The bytes following it,
|
|
||||||
// including an optional ` ACTIVE`, continue until the response CRLF.
|
|
||||||
let after_bytes = read_crlf_line(stream).await?;
|
let after_bytes = read_crlf_line(stream).await?;
|
||||||
let after = String::from_utf8(after_bytes)
|
let after = String::from_utf8(after_bytes)
|
||||||
.map_err(|_| SieveError::Protocol("literal suffix is not UTF-8".into()))?;
|
.map_err(|_| SieveError::Protocol("literal suffix is not UTF-8".into()))?;
|
||||||
@@ -477,7 +470,7 @@ fn trailing_literal_size(line: &str) -> Result<Option<usize>, SieveError> {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let raw = &trimmed[open + 1..trimmed.len() - 1];
|
let raw = &trimmed[open + 1..trimmed.len() - 1];
|
||||||
// Server-to-client literals do not use the non-synchronizing + form.
|
|
||||||
if raw.ends_with('+') {
|
if raw.ends_with('+') {
|
||||||
return Err(SieveError::Protocol(
|
return Err(SieveError::Protocol(
|
||||||
"server sent an invalid non-synchronizing literal".into(),
|
"server sent an invalid non-synchronizing literal".into(),
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ impl fmt::Display for SieveError {
|
|||||||
write!(f, "ManageSieve server rejected the command: {msg}")
|
write!(f, "ManageSieve server rejected the command: {msg}")
|
||||||
}
|
}
|
||||||
Self::Timeout => write!(f, "ManageSieve operation timed out"),
|
Self::Timeout => write!(f, "ManageSieve operation timed out"),
|
||||||
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.4 size limit"),
|
Self::ScriptTooLarge => write!(f, "Sieve script exceeds the RustBox 0.0.5 size limit"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! Shared RFC822/MIME builder used by SMTP delivery and IMAP Sent copies.
|
|
||||||
|
|
||||||
use lettre::message::{
|
use lettre::message::{
|
||||||
Attachment, Mailbox, MessageBuilder, MultiPart, SinglePart, header::ContentType,
|
Attachment, Mailbox, MessageBuilder, MultiPart, SinglePart, header::ContentType,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,46 +1,22 @@
|
|||||||
//! SMTP client abstraction and real implementation.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
// Import and re-export types for backward compatibility
|
|
||||||
pub use crate::smtp::error::SmtpError;
|
pub use crate::smtp::error::SmtpError;
|
||||||
pub use crate::smtp::types::{AttachmentData, SendableMessage, SmtpCredentials, SmtpSendResult};
|
pub use crate::smtp::types::{AttachmentData, SendableMessage, SmtpCredentials, SmtpSendResult};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Trait definition
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Abstraction over SMTP operations.
|
|
||||||
///
|
|
||||||
/// Every method receives explicit connection parameters so that the trait
|
|
||||||
/// remains stateless — no persistent connections are held.
|
|
||||||
///
|
|
||||||
/// The `Send + Sync` bounds allow implementations to be shared across
|
|
||||||
/// Tokio tasks and stored in `Arc`.
|
|
||||||
#[allow(clippy::double_must_use)]
|
#[allow(clippy::double_must_use)]
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SmtpClient: Send + Sync {
|
pub trait SmtpClient: Send + Sync {
|
||||||
/// Send an email message. Returns the generated Message-ID and exact RFC822 bytes.
|
|
||||||
async fn send_message(
|
async fn send_message(
|
||||||
&self,
|
&self,
|
||||||
creds: &SmtpCredentials,
|
creds: &SmtpCredentials,
|
||||||
message: &SendableMessage,
|
message: &SendableMessage,
|
||||||
) -> Result<SmtpSendResult, SmtpError>;
|
) -> Result<SmtpSendResult, SmtpError>;
|
||||||
|
|
||||||
/// Verify that the configured SMTP endpoint accepts an authenticated NOOP.
|
|
||||||
async fn check_connection(&self, _creds: &SmtpCredentials) -> Result<(), SmtpError> {
|
async fn check_connection(&self, _creds: &SmtpCredentials) -> Result<(), SmtpError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Real implementation backed by lettre
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Production SMTP client that uses `lettre`.
|
|
||||||
///
|
|
||||||
/// This is a stateless unit struct — every method creates a fresh connection,
|
|
||||||
/// performs the operation, and disconnects.
|
|
||||||
pub struct RealSmtpClient;
|
pub struct RealSmtpClient;
|
||||||
|
|
||||||
fn smtp_transport(
|
fn smtp_transport(
|
||||||
@@ -142,10 +118,6 @@ impl SmtpClient for RealSmtpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mock implementation (test-only)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "mock.rs"]
|
#[path = "mock.rs"]
|
||||||
pub mod mock;
|
pub mod mock;
|
||||||
+3
-7
@@ -1,18 +1,14 @@
|
|||||||
//! SMTP error types.
|
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
/// Errors that can occur during SMTP operations.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
#[allow(clippy::enum_variant_names)]
|
#[allow(clippy::enum_variant_names)]
|
||||||
pub enum SmtpError {
|
pub enum SmtpError {
|
||||||
/// Could not connect to the SMTP server.
|
|
||||||
ConnectionFailed(String),
|
ConnectionFailed(String),
|
||||||
/// The server rejected our credentials.
|
|
||||||
AuthenticationFailed,
|
AuthenticationFailed,
|
||||||
/// The message could not be sent.
|
|
||||||
SendFailed(String),
|
SendFailed(String),
|
||||||
/// The fully rendered RFC822/MIME payload exceeds the configured MTA limit.
|
|
||||||
MessageTooLarge { actual: usize, limit: usize },
|
MessageTooLarge { actual: usize, limit: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-21
@@ -1,23 +1,15 @@
|
|||||||
//! Mock SMTP client for testing.
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AttachmentData, SendableMessage, SmtpClient, SmtpCredentials, SmtpError, SmtpSendResult,
|
AttachmentData, SendableMessage, SmtpClient, SmtpCredentials, SmtpError, SmtpSendResult,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// A mock SMTP client that records sent messages.
|
|
||||||
///
|
|
||||||
/// Uses interior mutability (`Mutex`) so it can be shared behind `&self`.
|
|
||||||
/// Build it up with the `.with_*()` builder methods, then pass it wherever
|
|
||||||
/// an `&dyn SmtpClient` is needed.
|
|
||||||
pub struct MockSmtpClient {
|
pub struct MockSmtpClient {
|
||||||
should_fail: Mutex<Option<SmtpError>>,
|
should_fail: Mutex<Option<SmtpError>>,
|
||||||
sent_messages: Mutex<Vec<SendableMessage>>,
|
sent_messages: Mutex<Vec<SendableMessage>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockSmtpClient {
|
impl MockSmtpClient {
|
||||||
/// Create a new empty mock.
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
should_fail: Mutex::new(None),
|
should_fail: Mutex::new(None),
|
||||||
@@ -25,18 +17,15 @@ impl MockSmtpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make every subsequent call return this error.
|
|
||||||
pub fn with_error(self, error: SmtpError) -> Self {
|
pub fn with_error(self, error: SmtpError) -> Self {
|
||||||
*self.should_fail.lock().unwrap() = Some(error);
|
*self.should_fail.lock().unwrap() = Some(error);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the number of messages sent through this mock.
|
|
||||||
pub fn sent_count(&self) -> usize {
|
pub fn sent_count(&self) -> usize {
|
||||||
self.sent_messages.lock().unwrap().len()
|
self.sent_messages.lock().unwrap().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a clone of the most recently sent message, if any.
|
|
||||||
pub fn last_sent(&self) -> Option<SendableMessage> {
|
pub fn last_sent(&self) -> Option<SendableMessage> {
|
||||||
self.sent_messages.lock().unwrap().last().cloned()
|
self.sent_messages.lock().unwrap().last().cloned()
|
||||||
}
|
}
|
||||||
@@ -48,8 +37,6 @@ impl Default for MockSmtpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to clone an `SmtpError` for the mock (the real errors are not
|
|
||||||
/// `Clone`, so we reconstruct them).
|
|
||||||
fn clone_error(err: &SmtpError) -> SmtpError {
|
fn clone_error(err: &SmtpError) -> SmtpError {
|
||||||
match err {
|
match err {
|
||||||
SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()),
|
SmtpError::ConnectionFailed(msg) => SmtpError::ConnectionFailed(msg.clone()),
|
||||||
@@ -86,15 +73,10 @@ impl SmtpClient for MockSmtpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Convenience helper to build dummy credentials for tests.
|
|
||||||
fn test_creds() -> SmtpCredentials {
|
fn test_creds() -> SmtpCredentials {
|
||||||
SmtpCredentials {
|
SmtpCredentials {
|
||||||
host: "smtp.example.com".to_string(),
|
host: "smtp.example.com".to_string(),
|
||||||
@@ -106,7 +88,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a minimal test message.
|
|
||||||
fn test_message() -> SendableMessage {
|
fn test_message() -> SendableMessage {
|
||||||
SendableMessage {
|
SendableMessage {
|
||||||
from: "user@example.com".to_string(),
|
from: "user@example.com".to_string(),
|
||||||
@@ -215,7 +196,6 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, SmtpError::AuthenticationFailed));
|
assert!(matches!(err, SmtpError::AuthenticationFailed));
|
||||||
|
|
||||||
// Ensure no messages were recorded.
|
|
||||||
assert_eq!(mock.sent_count(), 0);
|
assert_eq!(mock.sent_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +215,7 @@ mod tests {
|
|||||||
let msg = test_message();
|
let msg = test_message();
|
||||||
|
|
||||||
let err = client.send_message(&creds, &msg).await.unwrap_err();
|
let err = client.send_message(&creds, &msg).await.unwrap_err();
|
||||||
// With a fake host the connection should fail.
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
err,
|
err,
|
||||||
|
|||||||
+19
-34
@@ -1,7 +1,3 @@
|
|||||||
//! SMTP data types.
|
|
||||||
|
|
||||||
/// Parameters needed to establish an SMTP connection.
|
|
||||||
/// Passed explicitly to every trait method so the trait stays stateless.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SmtpCredentials {
|
pub struct SmtpCredentials {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -18,42 +14,40 @@ pub struct SmtpSendResult {
|
|||||||
pub rfc822: Vec<u8>,
|
pub rfc822: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A message ready to be sent via SMTP.
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SendableMessage {
|
pub struct SendableMessage {
|
||||||
/// Sender email address.
|
|
||||||
pub from: String,
|
pub from: String,
|
||||||
/// Primary recipients.
|
|
||||||
pub to: Vec<String>,
|
pub to: Vec<String>,
|
||||||
/// CC recipients.
|
|
||||||
pub cc: Vec<String>,
|
pub cc: Vec<String>,
|
||||||
/// BCC recipients.
|
|
||||||
pub bcc: Vec<String>,
|
pub bcc: Vec<String>,
|
||||||
/// Optional Reply-To mailbox.
|
|
||||||
pub reply_to: Option<String>,
|
pub reply_to: Option<String>,
|
||||||
/// Optional organization label from the selected identity.
|
|
||||||
pub organization: Option<String>,
|
pub organization: Option<String>,
|
||||||
/// Subject line.
|
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
/// Plain-text body.
|
|
||||||
pub text_body: String,
|
pub text_body: String,
|
||||||
/// Optional HTML body.
|
|
||||||
pub html_body: Option<String>,
|
pub html_body: Option<String>,
|
||||||
/// In-Reply-To header value for threading.
|
|
||||||
pub in_reply_to: Option<String>,
|
pub in_reply_to: Option<String>,
|
||||||
/// References header value for threading.
|
|
||||||
pub references: Option<String>,
|
pub references: Option<String>,
|
||||||
/// Address that should receive an MDN/read receipt request.
|
|
||||||
pub read_receipt_to: Option<String>,
|
pub read_receipt_to: Option<String>,
|
||||||
/// Message priority: `high`, `normal`, or `low`.
|
|
||||||
pub priority: String,
|
pub priority: String,
|
||||||
/// Administrator-configured headers applied to every outgoing copy.
|
|
||||||
pub extra_headers: Vec<(String, String)>,
|
pub extra_headers: Vec<(String, String)>,
|
||||||
/// File attachments.
|
|
||||||
pub attachments: Vec<AttachmentData>,
|
pub attachments: Vec<AttachmentData>,
|
||||||
/// Optional OpenPGP transformation applied to the MIME entity.
|
|
||||||
pub pgp: Option<PgpSendConfig>,
|
pub pgp: Option<PgpSendConfig>,
|
||||||
/// Hard limit for the final RFC822/MIME payload submitted to SMTP.
|
|
||||||
pub max_message_size_bytes: usize,
|
pub max_message_size_bytes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,24 +68,17 @@ pub struct PgpSendConfig {
|
|||||||
pub recipient_public_keys: Vec<String>,
|
pub recipient_public_keys: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single file attachment to include in an outgoing message.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AttachmentData {
|
pub struct AttachmentData {
|
||||||
/// Filename as it should appear to the recipient.
|
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
/// MIME content type (e.g. "application/pdf").
|
|
||||||
pub content_type: String,
|
pub content_type: String,
|
||||||
/// Raw file content.
|
|
||||||
pub data: Vec<u8>,
|
pub data: Vec<u8>,
|
||||||
/// Optional Content-ID for inline images (referenced via `cid:` in HTML).
|
|
||||||
/// When set, the attachment is treated as an inline image rather than a
|
|
||||||
/// regular file attachment.
|
|
||||||
pub content_id: Option<String>,
|
pub content_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply RustBox-managed optional headers shared by SMTP delivery and the
|
|
||||||
/// copy appended to IMAP Sent. Header names are static and values are stripped
|
|
||||||
/// of CR/LF before they reach lettre's raw header API.
|
|
||||||
pub fn apply_optional_headers(
|
pub fn apply_optional_headers(
|
||||||
builder: lettre::message::MessageBuilder,
|
builder: lettre::message::MessageBuilder,
|
||||||
message: &SendableMessage,
|
message: &SendableMessage,
|
||||||
@@ -120,8 +107,6 @@ pub fn apply_optional_headers(
|
|||||||
builder
|
builder
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the optional outgoing headers without requiring a full SendableMessage.
|
|
||||||
/// This is also used for RFC822 draft snapshots appended to IMAP.
|
|
||||||
pub fn apply_optional_header_values(
|
pub fn apply_optional_header_values(
|
||||||
mut builder: lettre::message::MessageBuilder,
|
mut builder: lettre::message::MessageBuilder,
|
||||||
read_receipt_to: Option<&str>,
|
read_receipt_to: Option<&str>,
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
match relative {
|
match relative {
|
||||||
// Keep unversioned aliases non-cacheable for compatibility with old bookmarks/proxies.
|
|
||||||
"assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS),
|
"assets/app.css" => bytes_response("text/css; charset=utf-8", "no-store", APP_CSS),
|
||||||
"assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS),
|
"assets/app.js" => bytes_response("text/javascript; charset=utf-8", "no-store", APP_JS),
|
||||||
"assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS),
|
"assets/icons.svg" => bytes_response("image/svg+xml", "no-store", ICONS),
|
||||||
@@ -96,11 +95,26 @@ pub async fn serve_ui(uri: Uri, Extension(config): Extension<Arc<AppConfig>>) ->
|
|||||||
.replace("__RUSTBOX_ASSET_ID__", ASSET_ID)
|
.replace("__RUSTBOX_ASSET_ID__", ASSET_ID)
|
||||||
.replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION"))
|
.replace("__RUSTBOX_VERSION__", env!("CARGO_PKG_VERSION"))
|
||||||
.replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme)
|
.replace("__RUSTBOX_DEFAULT_THEME__", &config.default_theme)
|
||||||
.replace("__RUSTBOX_DEFAULT_INTERFACE_SKIN__", &config.default_interface_skin)
|
.replace(
|
||||||
.replace("__RUSTBOX_MAX_ATTACHMENT_BYTES__", &config.max_attachment_size_bytes().to_string())
|
"__RUSTBOX_DEFAULT_INTERFACE_SKIN__",
|
||||||
.replace("__RUSTBOX_MAX_ATTACHMENT_MB__", &config.max_attachment_size_mb.to_string())
|
&config.default_interface_skin,
|
||||||
.replace("__RUSTBOX_MAX_MESSAGE_BYTES__", &config.max_message_size_bytes().to_string())
|
)
|
||||||
.replace("__RUSTBOX_MAX_MESSAGE_MB__", &config.max_message_size_mb.to_string())
|
.replace(
|
||||||
|
"__RUSTBOX_MAX_ATTACHMENT_BYTES__",
|
||||||
|
&config.max_attachment_size_bytes().to_string(),
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"__RUSTBOX_MAX_ATTACHMENT_MB__",
|
||||||
|
&config.max_attachment_size_mb.to_string(),
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"__RUSTBOX_MAX_MESSAGE_BYTES__",
|
||||||
|
&config.max_message_size_bytes().to_string(),
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"__RUSTBOX_MAX_MESSAGE_MB__",
|
||||||
|
&config.max_message_size_mb.to_string(),
|
||||||
|
)
|
||||||
.replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim());
|
.replace("__RUSTBOX_OXI_BASE__", OXI_BASE.trim());
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ html[data-color-preset="red"] {
|
|||||||
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
--selected: color-mix(in srgb, var(--accent) 20%, var(--panel));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Advanced per-user interface controls. */
|
|
||||||
:root {
|
:root {
|
||||||
--toolbar-density-factor: 1;
|
--toolbar-density-factor: 1;
|
||||||
--sidebar-item-height: 29px;
|
--sidebar-item-height: 29px;
|
||||||
|
|||||||
+109
-5
@@ -754,7 +754,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reader toolbars must remain usable at every UI scale. */
|
|
||||||
.reader-toolbar {
|
.reader-toolbar {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -834,7 +834,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hierarchical folder tree. The disclosure control is separate from the folder action. */
|
|
||||||
.folder-row {
|
.folder-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 18px minmax(0, 1fr);
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
@@ -967,7 +967,7 @@ html[data-reduce-motion="true"] #thread-toggle svg {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A flagged message stays visually quiet while remaining easy to scan. */
|
|
||||||
.message-row.flagged {
|
.message-row.flagged {
|
||||||
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--danger) 58%, transparent);
|
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--danger) 58%, transparent);
|
||||||
}
|
}
|
||||||
@@ -979,7 +979,7 @@ html[data-reduce-motion="true"] #thread-toggle svg {
|
|||||||
padding: 0 1px;
|
padding: 0 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Advanced appearance preferences. */
|
|
||||||
.folder-pane {
|
.folder-pane {
|
||||||
background: var(--folder-bg);
|
background: var(--folder-bg);
|
||||||
}
|
}
|
||||||
@@ -1058,7 +1058,7 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Pane-aware anti-collision rules. These react to the pane width, not only viewport width. */
|
|
||||||
@container reader-pane (max-width: 640px) {
|
@container reader-pane (max-width: 640px) {
|
||||||
.reader-toolbar {
|
.reader-toolbar {
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
@@ -1193,4 +1193,108 @@ html[data-toolbar-density="spacious"] .reader-toolbar {
|
|||||||
#message-transfer-menu .toolbar-menu-popover {
|
#message-transfer-menu .toolbar-menu-popover {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-pane.reader-fullscreen {
|
||||||
|
display: block !important;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 240;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
max-width: none;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.reader-fullscreen-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-pane.reader-fullscreen .reader-content {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-pane.reader-fullscreen .message-header,
|
||||||
|
.reader-pane.reader-fullscreen .thread-section,
|
||||||
|
.reader-pane.reader-fullscreen .attachment-list,
|
||||||
|
.reader-pane.reader-fullscreen .remote-image-bar,
|
||||||
|
.reader-pane.reader-fullscreen .message-body {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.reader-toolbar {
|
||||||
|
min-height: 42px;
|
||||||
|
padding-block: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar .toolbar-menu>summary span {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar .icon-button,
|
||||||
|
.reader-toolbar .button,
|
||||||
|
.reader-toolbar .toolbar-menu>summary {
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar .toolbar-menu>summary {
|
||||||
|
padding-inline: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-quick-action {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-quick-action svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-menu-popover button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item .attachment-button {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-button span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header h1 {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.reader-toolbar .toolbar-menu>summary span {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar .toolbar-menu>summary {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -611,7 +611,7 @@
|
|||||||
min-height: 1.35em;
|
min-height: 1.35em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* OpenPGP composer / reader */
|
|
||||||
.pgp-reader-bar {
|
.pgp-reader-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1824,40 +1824,6 @@
|
|||||||
border-left-color: var(--accent);
|
border-left-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-progress {
|
|
||||||
grid-template-columns: 20px minmax(0, 1fr) auto 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-progress-content {
|
|
||||||
min-width: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-progress-track {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--panel-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-progress-bar {
|
|
||||||
display: block;
|
|
||||||
width: 0;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: var(--accent);
|
|
||||||
transition: width .12s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-progress-value {
|
|
||||||
min-width: 34px;
|
|
||||||
color: var(--muted);
|
|
||||||
font: 10px var(--font-mono);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|
||||||
@@ -1959,7 +1925,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dialog/layout hardening */
|
|
||||||
.contacts-card,
|
.contacts-card,
|
||||||
.calendar-card,
|
.calendar-card,
|
||||||
.headers-card,
|
.headers-card,
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
/* Roundcube-compatible visual skins.
|
|
||||||
Palette values are aligned to Roundcube Classic and Elastic upstream CSS.
|
|
||||||
Classic never shipped an official dark skin; the dark block is a contrast-preserving adaptation. */
|
|
||||||
|
|
||||||
/* ---------- Roundcube Classic ---------- */
|
|
||||||
html[data-interface-skin="roundcube-classic"] {
|
html[data-interface-skin="roundcube-classic"] {
|
||||||
--font-sans: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif;
|
--font-sans: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif;
|
||||||
--font-mono: Monaco, "Courier New", monospace;
|
--font-mono: Monaco, "Courier New", monospace;
|
||||||
@@ -193,7 +188,7 @@ html[data-interface-skin="roundcube-classic"] .message-row.active .message-state
|
|||||||
html[data-interface-skin="roundcube-classic"] .message-row.selected .message-date,
|
html[data-interface-skin="roundcube-classic"] .message-row.selected .message-date,
|
||||||
html[data-interface-skin="roundcube-classic"] .message-row.selected .message-snippet {
|
html[data-interface-skin="roundcube-classic"] .message-row.selected .message-snippet {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
border-color: rgba(255,255,255,.8);
|
border-color: rgba(255, 255, 255, .8);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-interface-skin="roundcube-classic"] .settings-tab {
|
html[data-interface-skin="roundcube-classic"] .settings-tab {
|
||||||
@@ -212,7 +207,7 @@ html[data-interface-skin="roundcube-classic"] .diagnostic-card {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Roundcube Elastic ---------- */
|
|
||||||
html[data-interface-skin="roundcube-elastic"] {
|
html[data-interface-skin="roundcube-elastic"] {
|
||||||
--font-sans: Roboto, "Segoe UI", Arial, sans-serif;
|
--font-sans: Roboto, "Segoe UI", Arial, sans-serif;
|
||||||
--radius-control: .3rem;
|
--radius-control: .3rem;
|
||||||
@@ -369,7 +364,7 @@ html[data-interface-skin="roundcube-elastic"] .appearance-preset:hover {
|
|||||||
border-color: #37beff;
|
border-color: #37beff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile drawer follows the active skin and uses Roundcube Elastic's back affordance. */
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.folder-drawer-header.mobile-only {
|
.folder-drawer-header.mobile-only {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -431,4 +426,4 @@ html[data-interface-skin="roundcube-elastic"] .appearance-preset:hover {
|
|||||||
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .folder-drawer-header {
|
html[data-interface-skin="roundcube-elastic"][data-theme="dark"] .folder-drawer-header {
|
||||||
background: #2f3a3f;
|
background: #2f3a3f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/* ---------- Roundcube Larry ---------- */
|
|
||||||
html[data-interface-skin="roundcube-larry"] {
|
html[data-interface-skin="roundcube-larry"] {
|
||||||
--font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif;
|
--font-sans: "Lucida Grande", "Segoe UI", Arial, Helvetica, sans-serif;
|
||||||
--radius-control: 3px;
|
--radius-control: 3px;
|
||||||
@@ -101,7 +100,7 @@ html[data-interface-skin="roundcube-larry"] .search-field:focus-within {
|
|||||||
box-shadow: 0 0 0 1px var(--accent);
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Clean ---------- */
|
|
||||||
html[data-interface-skin="clean"][data-theme="light"] {
|
html[data-interface-skin="clean"][data-theme="light"] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f5f7fa;
|
--bg: #f5f7fa;
|
||||||
@@ -177,7 +176,7 @@ html[data-interface-skin="clean"] .button.primary {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Graphite ---------- */
|
|
||||||
html[data-interface-skin="graphite"][data-theme="light"] {
|
html[data-interface-skin="graphite"][data-theme="light"] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #e4e6e8;
|
--bg: #e4e6e8;
|
||||||
@@ -254,7 +253,7 @@ html[data-interface-skin="graphite"] .button.primary {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- High contrast ---------- */
|
|
||||||
html[data-interface-skin="high-contrast"] {
|
html[data-interface-skin="high-contrast"] {
|
||||||
--radius-control: 2px;
|
--radius-control: 2px;
|
||||||
--radius-panel: 2px;
|
--radius-panel: 2px;
|
||||||
@@ -358,7 +357,7 @@ html[data-interface-skin="high-contrast"] .button.primary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Modern mail-inspired styles ---------- */
|
|
||||||
html[data-interface-skin="gmail"],
|
html[data-interface-skin="gmail"],
|
||||||
html[data-interface-skin="proton"],
|
html[data-interface-skin="proton"],
|
||||||
html[data-interface-skin="zoho"],
|
html[data-interface-skin="zoho"],
|
||||||
@@ -671,7 +670,7 @@ html[data-interface-skin="minimal"] .button.primary {
|
|||||||
color: var(--accent-ink);
|
color: var(--accent-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Additional predefined mail styles ---------- */
|
|
||||||
html[data-interface-skin="fastmail"],
|
html[data-interface-skin="fastmail"],
|
||||||
html[data-interface-skin="apple-mail"],
|
html[data-interface-skin="apple-mail"],
|
||||||
html[data-interface-skin="yahoo"],
|
html[data-interface-skin="yahoo"],
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* Dedicated full-screen composer mode. */
|
|
||||||
.compose-dialog.compose-fullscreen {
|
.compose-dialog.compose-fullscreen {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
@@ -271,7 +270,7 @@ html[data-preview="bottom"] .reader-pane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 0.0.3 mobile reliability pass */
|
|
||||||
.folder-quota {
|
.folder-quota {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -326,7 +325,7 @@ html[data-preview="bottom"] .reader-pane {
|
|||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The visual Sieve builder must never let checkbox inputs inherit width:100%. */
|
|
||||||
.sieve-builder .checkbox-row input[type="checkbox"] {
|
.sieve-builder .checkbox-row input[type="checkbox"] {
|
||||||
width: 15px;
|
width: 15px;
|
||||||
min-width: 15px;
|
min-width: 15px;
|
||||||
@@ -543,22 +542,19 @@ html[data-preview="bottom"] .reader-pane {
|
|||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.reader-toolbar .toolbar-menu>summary span {
|
.reader-toolbar .toolbar-menu>summary span {
|
||||||
display: none;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-toolbar .toolbar-menu>summary {
|
.reader-toolbar .toolbar-menu>summary {
|
||||||
min-width: 30px;
|
min-width: max-content;
|
||||||
|
min-height: 34px;
|
||||||
padding-inline: 7px;
|
padding-inline: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-toolbar .toolbar-menu-popover {
|
.reader-toolbar .toolbar-menu-popover {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: auto;
|
min-width: 180px;
|
||||||
right: 8px;
|
max-width: calc(100vw - 16px);
|
||||||
bottom: 8px;
|
|
||||||
left: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-footer {
|
.compose-footer {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* OpenPGP */
|
|
||||||
.pgp-reader-bar {
|
.pgp-reader-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+35
-11
@@ -1,5 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-base-path="__RUSTBOX_BASE_PATH__" data-default-theme="__RUSTBOX_DEFAULT_THEME__" data-default-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-max-attachment-bytes="__RUSTBOX_MAX_ATTACHMENT_BYTES__" data-max-attachment-mb="__RUSTBOX_MAX_ATTACHMENT_MB__" data-max-message-bytes="__RUSTBOX_MAX_MESSAGE_BYTES__" data-max-message-mb="__RUSTBOX_MAX_MESSAGE_MB__" data-theme="dark" data-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-density="compact" data-preview="right">
|
<html lang="en" data-base-path="__RUSTBOX_BASE_PATH__" data-default-theme="__RUSTBOX_DEFAULT_THEME__"
|
||||||
|
data-default-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__"
|
||||||
|
data-max-attachment-bytes="__RUSTBOX_MAX_ATTACHMENT_BYTES__" data-max-attachment-mb="__RUSTBOX_MAX_ATTACHMENT_MB__"
|
||||||
|
data-max-message-bytes="__RUSTBOX_MAX_MESSAGE_BYTES__" data-max-message-mb="__RUSTBOX_MAX_MESSAGE_MB__"
|
||||||
|
data-theme="dark" data-interface-skin="__RUSTBOX_DEFAULT_INTERFACE_SKIN__" data-density="compact"
|
||||||
|
data-preview="right">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
@@ -205,6 +210,10 @@
|
|||||||
aria-label="Next message"><svg>
|
aria-label="Next message"><svg>
|
||||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-chevron-right"></use>
|
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-chevron-right"></use>
|
||||||
</svg></button>
|
</svg></button>
|
||||||
|
<button id="reader-fullscreen" class="icon-button" type="button" title="Open full screen"
|
||||||
|
aria-label="Open message full screen" aria-pressed="false"><svg>
|
||||||
|
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-external-link"></use>
|
||||||
|
</svg></button>
|
||||||
<button id="reply-btn" class="button quiet" type="button"><svg>
|
<button id="reply-btn" class="button quiet" type="button"><svg>
|
||||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use>
|
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use>
|
||||||
</svg><span data-i18n="reply">Reply</span></button>
|
</svg><span data-i18n="reply">Reply</span></button>
|
||||||
@@ -270,7 +279,8 @@
|
|||||||
aria-label="Add sender to contacts"><svg>
|
aria-label="Add sender to contacts"><svg>
|
||||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
|
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
|
||||||
</svg></button></div>
|
</svg></button></div>
|
||||||
<div id="reader-to" class="reader-recipients" role="button" tabindex="0" aria-expanded="false" title="Show full addresses"></div>
|
<div id="reader-to" class="reader-recipients" role="button" tabindex="0" aria-expanded="false"
|
||||||
|
title="Show full addresses"></div>
|
||||||
</div><time id="reader-date"></time>
|
</div><time id="reader-date"></time>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -449,9 +459,12 @@
|
|||||||
<option value="text">Plain text</option>
|
<option value="text">Plain text</option>
|
||||||
<option value="markdown">Markdown</option>
|
<option value="markdown">Markdown</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
<span id="compose-status" class="muted"></span><span id="compose-message-size" class="muted compose-message-size" title="Estimated final message size">~0 / __RUSTBOX_MAX_MESSAGE_MB__ MiB</span>
|
<span id="compose-status" class="muted"></span><span id="compose-message-size"
|
||||||
<div class="compose-submit-actions"><button type="button" class="button quiet close-dialog">Cancel</button><button
|
class="muted compose-message-size" title="Estimated final message size">~0 / __RUSTBOX_MAX_MESSAGE_MB__
|
||||||
id="send-btn" type="submit" class="button primary"><svg>
|
MiB</span>
|
||||||
|
<div class="compose-submit-actions"><button type="button"
|
||||||
|
class="button quiet close-dialog">Cancel</button><button id="send-btn" type="submit"
|
||||||
|
class="button primary"><svg>
|
||||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use>
|
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use>
|
||||||
</svg><span>Send</span></button></div>
|
</svg><span>Send</span></button></div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -595,7 +608,10 @@
|
|||||||
<option value="nord">Nord</option>
|
<option value="nord">Nord</option>
|
||||||
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
|
</select><small id="pref-interface-skin-note" class="muted">RustBox is fully customizable: colors,
|
||||||
spacing, panes and typography.</small></label>
|
spacing, panes and typography.</small></label>
|
||||||
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input id="pref-preset-scale-unlocked" type="checkbox"><span>Allow UI scaling for predefined style</span><small class="muted">When enabled, the UI size control can override the scale defined by the selected preset.</small></label>
|
<label id="pref-preset-scale-unlocked-row" class="checkbox-row hidden"><input
|
||||||
|
id="pref-preset-scale-unlocked" type="checkbox"><span>Allow UI scaling for predefined
|
||||||
|
style</span><small class="muted">When enabled, the UI size control can override the scale defined by
|
||||||
|
the selected preset.</small></label>
|
||||||
<label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset">
|
<label><span data-i18n="color_preset">Color preset</span><select id="pref-color-preset">
|
||||||
<option value="sage">Sage</option>
|
<option value="sage">Sage</option>
|
||||||
<option value="blue">Blue</option>
|
<option value="blue">Blue</option>
|
||||||
@@ -858,8 +874,13 @@
|
|||||||
message text suggests a missing attachment</span></label>
|
message text suggests a missing attachment</span></label>
|
||||||
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
|
<label class="checkbox-row"><input id="compose-pref-spellcheck" type="checkbox"><span>Use browser
|
||||||
spellcheck while composing</span></label>
|
spellcheck while composing</span></label>
|
||||||
<label><span>Maximum attachment size</span><output id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</output><small class="muted">Per-file upload limit set with <code>max_attachment_size_mb</code> in rustbox.toml.</small></label>
|
<label><span>Maximum attachment size</span><output
|
||||||
<label><span>Maximum message size</span><output id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__ MiB</output><small class="muted">Hard limit for the complete RFC822/MIME message sent to the MTA, including base64/MIME overhead.</small></label>
|
id="compose-pref-max-attachment">__RUSTBOX_MAX_ATTACHMENT_MB__ MiB</output><small
|
||||||
|
class="muted">Per-file upload limit set with <code>max_attachment_size_mb</code> in
|
||||||
|
rustbox.toml.</small></label>
|
||||||
|
<label><span>Maximum message size</span><output id="compose-pref-max-message">__RUSTBOX_MAX_MESSAGE_MB__
|
||||||
|
MiB</output><small class="muted">Hard limit for the complete RFC822/MIME message sent to the MTA,
|
||||||
|
including base64/MIME overhead.</small></label>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
|
<p class="muted">Markdown is stored as source text and sent as a safe rendered HTML alternative with a
|
||||||
plain-text fallback.</p>
|
plain-text fallback.</p>
|
||||||
@@ -1073,10 +1094,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="diagnostics-grid">
|
<div class="diagnostics-grid">
|
||||||
<section class="diagnostic-card">
|
<section class="diagnostic-card">
|
||||||
<div class="section-heading"><span>IMAP</span><strong id="diagnostic-imap-status">Not checked</strong></div>
|
<div class="section-heading"><span>IMAP</span><strong id="diagnostic-imap-status">Not checked</strong>
|
||||||
|
</div>
|
||||||
<div id="diagnostic-imap-summary" class="capability-grid"></div>
|
<div id="diagnostic-imap-summary" class="capability-grid"></div>
|
||||||
<div id="diagnostic-imap-caps" class="capability-list"></div>
|
<div id="diagnostic-imap-caps" class="capability-list"></div>
|
||||||
<div class="inline-actions diagnostic-imap-actions"><button id="diagnostic-imap-reconnect" class="button quiet" type="button">Reconnect</button></div>
|
<div class="inline-actions diagnostic-imap-actions"><button id="diagnostic-imap-reconnect"
|
||||||
|
class="button quiet" type="button">Reconnect</button></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="diagnostic-card">
|
<section class="diagnostic-card">
|
||||||
<div class="section-heading"><span>SMTP</span><strong id="diagnostic-smtp-status">Not checked</strong>
|
<div class="section-heading"><span>SMTP</span><strong id="diagnostic-smtp-status">Not checked</strong>
|
||||||
@@ -1139,7 +1162,8 @@
|
|||||||
<dt>Author</dt>
|
<dt>Author</dt>
|
||||||
<dd>@linuxiarz.pl</dd>
|
<dd>@linuxiarz.pl</dd>
|
||||||
<dt>Repository</dt>
|
<dt>Repository</dt>
|
||||||
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank" rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
|
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank"
|
||||||
|
rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard
|
<div class="inline-actions"><button id="shortcuts-open" class="button quiet" type="button">Keyboard
|
||||||
shortcuts</button></div>
|
shortcuts</button></div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* RustBox UI module: 00-bootstrap.js */
|
|
||||||
(() => {
|
(() => {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -50,13 +50,23 @@
|
|||||||
markReadTimer: null,
|
markReadTimer: null,
|
||||||
ws: null,
|
ws: null,
|
||||||
wsRetryTimer: null,
|
wsRetryTimer: null,
|
||||||
|
wsRetryAttempt: 0,
|
||||||
|
wsWatchdogTimer: null,
|
||||||
|
wsLastHeartbeatAt: 0,
|
||||||
|
wsShouldConnect: false,
|
||||||
|
wsConnectedOnce: false,
|
||||||
realtimeRefreshTimer: null,
|
realtimeRefreshTimer: null,
|
||||||
|
realtimeRefreshEvents: [],
|
||||||
|
realtimePollTimer: null,
|
||||||
|
realtimeSyncPromise: null,
|
||||||
|
realtimeSeenEvents: new Map(),
|
||||||
calendarReminderTimer: null,
|
calendarReminderTimer: null,
|
||||||
composeDraftId: null,
|
composeDraftId: null,
|
||||||
composeAttachments: [],
|
composeAttachments: [],
|
||||||
composeDirty: false,
|
composeDirty: false,
|
||||||
composeNeedsRemoteSync: false,
|
composeNeedsRemoteSync: false,
|
||||||
composeUploading: false,
|
composeUploading: false,
|
||||||
|
composeUploadController: null,
|
||||||
composeReplyMeta: null,
|
composeReplyMeta: null,
|
||||||
composeInsertRange: null,
|
composeInsertRange: null,
|
||||||
composeSelectedImage: null,
|
composeSelectedImage: null,
|
||||||
@@ -79,6 +89,8 @@
|
|||||||
quotaWarningLevel: null,
|
quotaWarningLevel: null,
|
||||||
networkOnline: navigator.onLine,
|
networkOnline: navigator.onLine,
|
||||||
listStateTimer: null,
|
listStateTimer: null,
|
||||||
|
readerFullscreenHistory: false,
|
||||||
|
readerScrollTopBeforeFullscreen: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (selector, parent = document) => parent.querySelector(selector);
|
const $ = (selector, parent = document) => parent.querySelector(selector);
|
||||||
@@ -105,8 +117,8 @@
|
|||||||
|
|
||||||
function stripRustBoxUiLeak(value) {
|
function stripRustBoxUiLeak(value) {
|
||||||
let text = String(value ?? "");
|
let text = String(value ?? "");
|
||||||
// Strip only RustBox's own embedded SVG sprite fragments if malformed MIME
|
|
||||||
// content captured adjacent UI markup. Legitimate SVG/code in mail remains.
|
|
||||||
text = text.replace(/<svg\b[^>]*>\s*<use\b[^>]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, "");
|
text = text.replace(/<svg\b[^>]*>\s*<use\b[^>]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, "");
|
||||||
text = text.replace(/<svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?<\/svg>/gi, "");
|
text = text.replace(/<svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?<\/svg>/gi, "");
|
||||||
return text;
|
return text;
|
||||||
|
|||||||
+19
-72
@@ -1,4 +1,4 @@
|
|||||||
/* RustBox UI module: 10-core.js */
|
|
||||||
function setConnectivityState(online, detail = "") {
|
function setConnectivityState(online, detail = "") {
|
||||||
state.networkOnline = Boolean(online);
|
state.networkOnline = Boolean(online);
|
||||||
document.body.classList.toggle("network-offline", !state.networkOnline);
|
document.body.classList.toggle("network-offline", !state.networkOnline);
|
||||||
@@ -124,9 +124,17 @@ async function request(path, options = {}) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadRequest(path, formData, onProgress = null) {
|
function uploadRequest(path, formData, onProgress = null, signal = null) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
|
let settled = false;
|
||||||
|
const finish = (callback, value) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
signal?.removeEventListener("abort", abortUpload);
|
||||||
|
callback(value);
|
||||||
|
};
|
||||||
|
const abortUpload = () => xhr.abort();
|
||||||
xhr.open("POST", apiPath(path), true);
|
xhr.open("POST", apiPath(path), true);
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
|
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
|
||||||
@@ -136,8 +144,8 @@ function uploadRequest(path, formData, onProgress = null) {
|
|||||||
if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
|
if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
|
xhr.addEventListener("error", () => finish(reject, new Error("Upload failed: network error")));
|
||||||
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
|
xhr.addEventListener("abort", () => finish(reject, new DOMException("Upload cancelled", "AbortError")));
|
||||||
xhr.addEventListener("load", () => {
|
xhr.addEventListener("load", () => {
|
||||||
const contentType = xhr.getResponseHeader("content-type") || "";
|
const contentType = xhr.getResponseHeader("content-type") || "";
|
||||||
let payload = xhr.responseText || "";
|
let payload = xhr.responseText || "";
|
||||||
@@ -148,11 +156,16 @@ function uploadRequest(path, formData, onProgress = null) {
|
|||||||
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
|
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
|
||||||
const error = new Error(message);
|
const error = new Error(message);
|
||||||
error.status = xhr.status;
|
error.status = xhr.status;
|
||||||
reject(error);
|
finish(reject, error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(payload);
|
finish(resolve, payload);
|
||||||
});
|
});
|
||||||
|
if (signal?.aborted) {
|
||||||
|
finish(reject, new DOMException("Upload cancelled", "AbortError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal?.addEventListener("abort", abortUpload, { once: true });
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -251,72 +264,6 @@ function toast(message, kind = "info", duration = null, action = null) {
|
|||||||
resume();
|
resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
function progressToast(message) {
|
|
||||||
const node = el("div", "toast toast-info toast-progress");
|
|
||||||
node.setAttribute("role", "status");
|
|
||||||
const icon = el("span", "toast-icon");
|
|
||||||
icon.innerHTML = svgIcon("paperclip");
|
|
||||||
const content = el("span", "toast-progress-content");
|
|
||||||
const text = el("span", "toast-message", message);
|
|
||||||
const track = el("span", "toast-progress-track");
|
|
||||||
const bar = el("span", "toast-progress-bar");
|
|
||||||
track.append(bar);
|
|
||||||
content.append(text, track);
|
|
||||||
const value = el("span", "toast-progress-value", "0%");
|
|
||||||
const close = el("button", "toast-close");
|
|
||||||
close.type = "button";
|
|
||||||
close.setAttribute("aria-label", "Dismiss notification");
|
|
||||||
close.innerHTML = svgIcon("x");
|
|
||||||
let stack = $("#toast-stack");
|
|
||||||
const openDialogs = $$("dialog[open]");
|
|
||||||
if (openDialogs.length) {
|
|
||||||
const dialog = openDialogs[openDialogs.length - 1];
|
|
||||||
stack = dialog.querySelector(":scope > .modal-toast-stack");
|
|
||||||
if (!stack) {
|
|
||||||
stack = el("div", "toast-stack modal-toast-stack");
|
|
||||||
stack.setAttribute("aria-live", "polite");
|
|
||||||
dialog.append(stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
node.append(icon, content, value, close);
|
|
||||||
stack.append(node);
|
|
||||||
requestAnimationFrame(() => node.classList.add("visible"));
|
|
||||||
let dismissed = false;
|
|
||||||
const dismiss = (delay = 0) => {
|
|
||||||
if (dismissed) return;
|
|
||||||
dismissed = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
node.classList.remove("visible");
|
|
||||||
setTimeout(() => node.remove(), 160);
|
|
||||||
}, Math.max(0, delay));
|
|
||||||
};
|
|
||||||
close.addEventListener("click", () => dismiss());
|
|
||||||
return {
|
|
||||||
update(percent, label = null) {
|
|
||||||
const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
|
|
||||||
bar.style.width = `${normalized}%`;
|
|
||||||
value.textContent = `${normalized}%`;
|
|
||||||
if (label) text.textContent = label;
|
|
||||||
},
|
|
||||||
complete(label = trText("Upload complete")) {
|
|
||||||
bar.style.width = "100%";
|
|
||||||
value.textContent = "100%";
|
|
||||||
text.textContent = label;
|
|
||||||
node.classList.remove("toast-info");
|
|
||||||
node.classList.add("toast-success");
|
|
||||||
icon.innerHTML = svgIcon("check-circle");
|
|
||||||
dismiss(1200);
|
|
||||||
},
|
|
||||||
fail(label) {
|
|
||||||
text.textContent = label || trText("Upload failed");
|
|
||||||
node.classList.remove("toast-info");
|
|
||||||
node.classList.add("toast-error");
|
|
||||||
icon.innerHTML = svgIcon("circle-alert");
|
|
||||||
dismiss(4800);
|
|
||||||
},
|
|
||||||
dismiss,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
|
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* RustBox UI module: 20-preferences-auth.js */
|
|
||||||
function resolveTheme(theme) {
|
function resolveTheme(theme) {
|
||||||
if (theme !== "system") return theme;
|
if (theme !== "system") return theme;
|
||||||
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||||
|
|||||||
+437
-437
@@ -1,453 +1,453 @@
|
|||||||
/* RustBox UI module: 30-folders-tags.js */
|
|
||||||
function folderIconName(folder) {
|
|
||||||
const attrs = (folder.attributes || []).join(" ").toLowerCase();
|
|
||||||
const name = String(folder.name || "").toLowerCase();
|
|
||||||
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
|
|
||||||
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
|
|
||||||
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
|
|
||||||
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
|
|
||||||
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
|
|
||||||
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
|
|
||||||
return "folder";
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderedFolders() {
|
function folderIconName(folder) {
|
||||||
return [...state.folders].sort((a, b) => {
|
const attrs = (folder.attributes || []).join(" ").toLowerCase();
|
||||||
if (a.name.toUpperCase() === "INBOX") return -1;
|
const name = String(folder.name || "").toLowerCase();
|
||||||
if (b.name.toUpperCase() === "INBOX") return 1;
|
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
|
||||||
return a.name.localeCompare(b.name);
|
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
|
||||||
|
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
|
||||||
|
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
|
||||||
|
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
|
||||||
|
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
|
||||||
|
return "folder";
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedFolders() {
|
||||||
|
return [...state.folders].sort((a, b) => {
|
||||||
|
if (a.name.toUpperCase() === "INBOX") return -1;
|
||||||
|
if (b.name.toUpperCase() === "INBOX") return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderDepth(folder) {
|
||||||
|
const delimiter = folder.delimiter || "/";
|
||||||
|
if (!delimiter || !folder.name.includes(delimiter)) return 0;
|
||||||
|
return Math.min(8, folder.name.split(delimiter).length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderLeafName(folder) {
|
||||||
|
const delimiter = folder.delimiter || "/";
|
||||||
|
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
|
||||||
|
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderHasChildren(folder, folders = state.folders) {
|
||||||
|
const delimiter = folder.delimiter || "/";
|
||||||
|
if (!delimiter) return false;
|
||||||
|
const prefix = `${folder.name}${delimiter}`;
|
||||||
|
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderAncestors(folder) {
|
||||||
|
const delimiter = folder.delimiter || "/";
|
||||||
|
if (!delimiter || !folder.name.includes(delimiter)) return [];
|
||||||
|
const parts = folder.name.split(delimiter);
|
||||||
|
const result = [];
|
||||||
|
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapsedFolderStorageKey() {
|
||||||
|
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCollapsedFolders() {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
|
||||||
|
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
|
||||||
|
} catch (_) {
|
||||||
|
state.collapsedFolders = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCollapsedFolders() {
|
||||||
|
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderIsHiddenByCollapsedAncestor(folder) {
|
||||||
|
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFolderBranch(folder) {
|
||||||
|
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
|
||||||
|
else state.collapsedFolders.add(folder.name);
|
||||||
|
saveCollapsedFolders();
|
||||||
|
renderFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFolderDrawerOpen(open) {
|
||||||
|
const shouldOpen = Boolean(open) && innerWidth <= 820;
|
||||||
|
$("#folder-pane")?.classList.toggle("open", shouldOpen);
|
||||||
|
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
|
||||||
|
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFolderDrawer() {
|
||||||
|
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolders() {
|
||||||
|
const list = $("#folder-list");
|
||||||
|
list.replaceChildren();
|
||||||
|
const folders = orderedFolders();
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
|
||||||
|
const row = el("div", "folder-row");
|
||||||
|
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
|
||||||
|
|
||||||
|
const hasChildren = folderHasChildren(folder, folders);
|
||||||
|
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
|
||||||
|
expander.type = "button";
|
||||||
|
expander.tabIndex = hasChildren ? 0 : -1;
|
||||||
|
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
|
||||||
|
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
|
||||||
|
if (hasChildren) {
|
||||||
|
expander.innerHTML = svgIcon("chevron-right");
|
||||||
|
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
|
||||||
|
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
|
||||||
|
button.type = "button";
|
||||||
|
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
|
||||||
|
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
|
||||||
|
button.addEventListener("dragover", (event) => {
|
||||||
|
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
button.classList.add("drop-target");
|
||||||
});
|
});
|
||||||
}
|
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
|
||||||
|
button.addEventListener("drop", async (event) => {
|
||||||
function folderDepth(folder) {
|
event.preventDefault();
|
||||||
const delimiter = folder.delimiter || "/";
|
button.classList.remove("drop-target");
|
||||||
if (!delimiter || !folder.name.includes(delimiter)) return 0;
|
try {
|
||||||
return Math.min(8, folder.name.split(delimiter).length - 1);
|
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
|
||||||
}
|
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
|
||||||
|
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
|
||||||
function folderLeafName(folder) {
|
} catch (error) { toast(error.message || "Could not move message", "error"); }
|
||||||
const delimiter = folder.delimiter || "/";
|
});
|
||||||
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
|
button.addEventListener("click", async () => {
|
||||||
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
|
saveMessageListState();
|
||||||
}
|
state.folder = folder.name;
|
||||||
|
state.selected = null;
|
||||||
function folderHasChildren(folder, folders = state.folders) {
|
|
||||||
const delimiter = folder.delimiter || "/";
|
|
||||||
if (!delimiter) return false;
|
|
||||||
const prefix = `${folder.name}${delimiter}`;
|
|
||||||
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
|
|
||||||
}
|
|
||||||
|
|
||||||
function folderAncestors(folder) {
|
|
||||||
const delimiter = folder.delimiter || "/";
|
|
||||||
if (!delimiter || !folder.name.includes(delimiter)) return [];
|
|
||||||
const parts = folder.name.split(delimiter);
|
|
||||||
const result = [];
|
|
||||||
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapsedFolderStorageKey() {
|
|
||||||
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadCollapsedFolders() {
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
|
|
||||||
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
|
|
||||||
} catch (_) {
|
|
||||||
state.collapsedFolders = new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveCollapsedFolders() {
|
|
||||||
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function folderIsHiddenByCollapsedAncestor(folder) {
|
|
||||||
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFolderBranch(folder) {
|
|
||||||
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
|
|
||||||
else state.collapsedFolders.add(folder.name);
|
|
||||||
saveCollapsedFolders();
|
|
||||||
renderFolders();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFolderDrawerOpen(open) {
|
|
||||||
const shouldOpen = Boolean(open) && innerWidth <= 820;
|
|
||||||
$("#folder-pane")?.classList.toggle("open", shouldOpen);
|
|
||||||
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
|
|
||||||
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFolderDrawer() {
|
|
||||||
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFolders() {
|
|
||||||
const list = $("#folder-list");
|
|
||||||
list.replaceChildren();
|
|
||||||
const folders = orderedFolders();
|
|
||||||
for (const folder of folders) {
|
|
||||||
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
|
|
||||||
const row = el("div", "folder-row");
|
|
||||||
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
|
|
||||||
|
|
||||||
const hasChildren = folderHasChildren(folder, folders);
|
|
||||||
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
|
|
||||||
expander.type = "button";
|
|
||||||
expander.tabIndex = hasChildren ? 0 : -1;
|
|
||||||
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
|
|
||||||
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
|
|
||||||
if (hasChildren) {
|
|
||||||
expander.innerHTML = svgIcon("chevron-right");
|
|
||||||
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
|
|
||||||
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
|
|
||||||
}
|
|
||||||
|
|
||||||
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
|
|
||||||
button.type = "button";
|
|
||||||
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
|
|
||||||
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
|
|
||||||
button.addEventListener("dragover", (event) => {
|
|
||||||
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = "move";
|
|
||||||
button.classList.add("drop-target");
|
|
||||||
});
|
|
||||||
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
|
|
||||||
button.addEventListener("drop", async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
button.classList.remove("drop-target");
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
|
|
||||||
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
|
|
||||||
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
|
|
||||||
} catch (error) { toast(error.message || "Could not move message", "error"); }
|
|
||||||
});
|
|
||||||
button.addEventListener("click", async () => {
|
|
||||||
saveMessageListState();
|
|
||||||
state.folder = folder.name;
|
|
||||||
state.selected = null;
|
|
||||||
state.selectedMessages.clear();
|
|
||||||
clearActiveSearchContext();
|
|
||||||
state.currentDetail = null;
|
|
||||||
renderFolders();
|
|
||||||
closeReader();
|
|
||||||
setFolderDrawerOpen(false);
|
|
||||||
await loadMessages(true, { restoreState: true });
|
|
||||||
});
|
|
||||||
row.append(expander, button);
|
|
||||||
list.append(row);
|
|
||||||
}
|
|
||||||
renderMoveOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUnreadAppBadge() {
|
|
||||||
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
|
|
||||||
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
|
|
||||||
try {
|
|
||||||
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
|
|
||||||
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
|
|
||||||
} catch (_) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFolders(restoreView = false) {
|
|
||||||
$("#connection-label").textContent = "Connecting";
|
|
||||||
try {
|
|
||||||
const data = await request("/folders", { method: "GET" });
|
|
||||||
state.folders = data.folders || [];
|
|
||||||
updateUnreadAppBadge();
|
|
||||||
loadCollapsedFolders();
|
|
||||||
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
|
|
||||||
if (restoreView) {
|
|
||||||
const remembered = localStorage.getItem(lastFolderStorageKey());
|
|
||||||
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
|
|
||||||
}
|
|
||||||
if (!state.folders.some((folder) => folder.name === state.folder)) {
|
|
||||||
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
|
|
||||||
}
|
|
||||||
renderFolders();
|
|
||||||
$("#connection-label").textContent = "Connected";
|
|
||||||
if (restoreView) {
|
|
||||||
const restoredSearch = readActiveSearchContext();
|
|
||||||
if (restoredSearch) {
|
|
||||||
state.searchContext = restoredSearch;
|
|
||||||
const restoredParams = new URLSearchParams(restoredSearch.params);
|
|
||||||
$("#search-input").value = restoredParams.get("q") || "";
|
|
||||||
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
|
||||||
else await loadMessages(true, { restoreState: restoreView });
|
|
||||||
} catch (error) {
|
|
||||||
$("#connection-label").textContent = "Connection error";
|
|
||||||
if (error.status === 401) return showLogin();
|
|
||||||
toast(error.message, "error");
|
|
||||||
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMoveOptions() {
|
|
||||||
const selects = [
|
|
||||||
["#move-select", "Move to…"],
|
|
||||||
["#copy-select", "Copy to…"],
|
|
||||||
["#bulk-move-select", "Move to…"],
|
|
||||||
["#bulk-copy-select", "Copy to…"],
|
|
||||||
];
|
|
||||||
for (const [selector, placeholder] of selects) {
|
|
||||||
const select = $(selector);
|
|
||||||
if (!select) continue;
|
|
||||||
select.replaceChildren(new Option(placeholder, ""));
|
|
||||||
for (const folder of orderedFolders()) {
|
|
||||||
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
|
|
||||||
select.add(new Option(folder.name, folder.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageKey(message) {
|
|
||||||
return `${message.folder || state.folder}\u0000${message.uid}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeSearchHighlightTerms() {
|
|
||||||
if (!state.searchContext?.params) return [];
|
|
||||||
const params = new URLSearchParams(state.searchContext.params);
|
|
||||||
const terms = [];
|
|
||||||
for (const key of ["from", "to", "subject"]) {
|
|
||||||
const value = (params.get(key) || "").trim();
|
|
||||||
if (value) terms.push(value);
|
|
||||||
}
|
|
||||||
const query = (params.get("q") || "").trim();
|
|
||||||
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
||||||
for (const token of tokens) {
|
|
||||||
const cleaned = token.replace(/^"|"$/g, "");
|
|
||||||
const colon = cleaned.indexOf(":");
|
|
||||||
if (colon > 0) {
|
|
||||||
const op = cleaned.slice(0, colon).toLowerCase();
|
|
||||||
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
|
|
||||||
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
|
|
||||||
} else if (cleaned.length >= 2) {
|
|
||||||
terms.push(cleaned);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
|
|
||||||
.sort((a, b) => b.length - a.length)
|
|
||||||
.slice(0, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHighlightedText(node, value, terms) {
|
|
||||||
const text = String(value || "");
|
|
||||||
node.replaceChildren();
|
|
||||||
if (!terms.length || !text) {
|
|
||||||
node.textContent = text;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lower = text.toLocaleLowerCase();
|
|
||||||
let offset = 0;
|
|
||||||
while (offset < text.length) {
|
|
||||||
let matchIndex = -1;
|
|
||||||
let matchTerm = "";
|
|
||||||
for (const term of terms) {
|
|
||||||
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
|
|
||||||
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
|
|
||||||
matchIndex = index;
|
|
||||||
matchTerm = term;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (matchIndex < 0) {
|
|
||||||
node.append(document.createTextNode(text.slice(offset)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
|
|
||||||
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
|
|
||||||
node.append(mark);
|
|
||||||
offset = matchIndex + matchTerm.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedMessages() {
|
|
||||||
const source = state.searchAllResults || state.messages;
|
|
||||||
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBulkToolbar() {
|
|
||||||
const selected = selectedMessages();
|
|
||||||
const toolbar = $("#bulk-toolbar");
|
|
||||||
toolbar.classList.toggle("hidden", !state.selectionMode);
|
|
||||||
$("#bulk-count").textContent = `${selected.length} selected`;
|
|
||||||
const selectionUniverse = state.searchAllResults || state.messages;
|
|
||||||
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
|
|
||||||
$("#bulk-select-all").checked = allLoaded;
|
|
||||||
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
|
|
||||||
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
|
|
||||||
const selectSearch = $("#bulk-select-search");
|
|
||||||
if (selectSearch) {
|
|
||||||
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
|
|
||||||
selectSearch.disabled = !state.searchContext;
|
|
||||||
}
|
|
||||||
const junkButton = $("#bulk-junk");
|
|
||||||
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
|
|
||||||
const toggle = $("#select-mode-btn");
|
|
||||||
if (toggle) {
|
|
||||||
toggle.classList.toggle("active", state.selectionMode);
|
|
||||||
toggle.setAttribute("aria-pressed", String(state.selectionMode));
|
|
||||||
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
|
|
||||||
toggle.setAttribute("aria-label", toggle.title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSelectionMode(enabled) {
|
|
||||||
state.selectionMode = Boolean(enabled);
|
|
||||||
if (!state.selectionMode) {
|
|
||||||
state.selectedMessages.clear();
|
|
||||||
state.searchAllResults = null;
|
|
||||||
}
|
|
||||||
renderMessages();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMessageSelected(message, selected) {
|
|
||||||
if (!state.selectionMode) state.selectionMode = true;
|
|
||||||
const key = messageKey(message);
|
|
||||||
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
|
|
||||||
renderMessages();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearBulkSelection() {
|
|
||||||
state.selectedMessages.clear();
|
|
||||||
state.searchAllResults = null;
|
|
||||||
renderMessages();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectAllSearchResults() {
|
|
||||||
if (!state.searchContext) return;
|
|
||||||
const button = $("#bulk-select-search");
|
|
||||||
setBusy(button, true, "Selecting…");
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams(state.searchContext.params);
|
|
||||||
params.set("all", "true");
|
|
||||||
params.delete("offset");
|
|
||||||
const data = await request(`/search?${params.toString()}`, { method: "GET" });
|
|
||||||
state.searchAllResults = data.results || [];
|
|
||||||
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
|
|
||||||
state.total = state.searchAllResults.length;
|
|
||||||
renderMessages();
|
|
||||||
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
|
|
||||||
} catch (error) {
|
|
||||||
toast(error.message, "error");
|
|
||||||
} finally {
|
|
||||||
setBusy(button, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTagOptions() {
|
|
||||||
const select = $("#bulk-tag-select");
|
|
||||||
if (!select) return;
|
|
||||||
select.replaceChildren(new Option("Add label…", ""));
|
|
||||||
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTags() {
|
|
||||||
const list = $("#tag-list");
|
|
||||||
if (!list) return;
|
|
||||||
list.replaceChildren();
|
|
||||||
for (const tag of state.tags) {
|
|
||||||
const button = el("button", "tag-item");
|
|
||||||
button.type = "button";
|
|
||||||
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
|
|
||||||
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
|
|
||||||
button.addEventListener("click", () => loadTagMessages(tag));
|
|
||||||
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
|
|
||||||
list.append(button);
|
|
||||||
}
|
|
||||||
renderTagOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTags() {
|
|
||||||
try {
|
|
||||||
const data = await request("/tags", { method: "GET" });
|
|
||||||
state.tags = data.tags || [];
|
|
||||||
renderTags();
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTagMessages(tag) {
|
|
||||||
try {
|
|
||||||
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
|
|
||||||
state.messages = data.messages || [];
|
|
||||||
state.total = data.total_count || state.messages.length;
|
|
||||||
state.selectedMessages.clear();
|
state.selectedMessages.clear();
|
||||||
clearActiveSearchContext();
|
clearActiveSearchContext();
|
||||||
renderMessages();
|
state.currentDetail = null;
|
||||||
$("#folder-title").textContent = `Label: ${tag.name}`;
|
renderFolders();
|
||||||
$("#message-count").textContent = `${state.total} results`;
|
closeReader();
|
||||||
} catch (error) { toast(error.message, "error"); }
|
setFolderDrawerOpen(false);
|
||||||
|
await loadMessages(true, { restoreState: true });
|
||||||
|
});
|
||||||
|
row.append(expander, button);
|
||||||
|
list.append(row);
|
||||||
}
|
}
|
||||||
|
renderMoveOptions();
|
||||||
|
}
|
||||||
|
|
||||||
function openTagDialog(tag = null) {
|
function updateUnreadAppBadge() {
|
||||||
$("#tag-id").value = tag?.id || "";
|
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
|
||||||
$("#tag-name").value = tag?.name || "";
|
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
|
||||||
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280";
|
try {
|
||||||
$("#tag-delete").disabled = !tag;
|
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
|
||||||
$("#tag-dialog").showModal();
|
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
|
||||||
setTimeout(() => $("#tag-name").focus(), 20);
|
} catch (_) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFolders(restoreView = false) {
|
||||||
|
$("#connection-label").textContent = "Connecting";
|
||||||
|
try {
|
||||||
|
const data = await request("/folders", { method: "GET" });
|
||||||
|
state.folders = data.folders || [];
|
||||||
|
updateUnreadAppBadge();
|
||||||
|
loadCollapsedFolders();
|
||||||
|
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
|
||||||
|
if (restoreView) {
|
||||||
|
const remembered = localStorage.getItem(lastFolderStorageKey());
|
||||||
|
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
|
||||||
|
}
|
||||||
|
if (!state.folders.some((folder) => folder.name === state.folder)) {
|
||||||
|
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
|
||||||
|
}
|
||||||
|
renderFolders();
|
||||||
|
$("#connection-label").textContent = "Connected";
|
||||||
|
if (restoreView) {
|
||||||
|
const restoredSearch = readActiveSearchContext();
|
||||||
|
if (restoredSearch) {
|
||||||
|
state.searchContext = restoredSearch;
|
||||||
|
const restoredParams = new URLSearchParams(restoredSearch.params);
|
||||||
|
$("#search-input").value = restoredParams.get("q") || "";
|
||||||
|
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||||
|
else await loadMessages(true, { restoreState: restoreView });
|
||||||
|
} catch (error) {
|
||||||
|
$("#connection-label").textContent = "Connection error";
|
||||||
|
if (error.status === 401) return showLogin();
|
||||||
|
toast(error.message, "error");
|
||||||
|
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTag(event) {
|
function renderMoveOptions() {
|
||||||
event.preventDefault();
|
const selects = [
|
||||||
const id = $("#tag-id").value;
|
["#move-select", "Move to…"],
|
||||||
const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value };
|
["#copy-select", "Copy to…"],
|
||||||
if (!payload.name) return;
|
["#bulk-move-select", "Move to…"],
|
||||||
try {
|
["#bulk-copy-select", "Copy to…"],
|
||||||
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
|
];
|
||||||
$("#tag-dialog").close();
|
for (const [selector, placeholder] of selects) {
|
||||||
await loadTags();
|
const select = $(selector);
|
||||||
toast("Label saved");
|
if (!select) continue;
|
||||||
} catch (error) { toast(error.message, "error"); }
|
select.replaceChildren(new Option(placeholder, ""));
|
||||||
|
for (const folder of orderedFolders()) {
|
||||||
|
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
|
||||||
|
select.add(new Option(folder.name, folder.name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteTag() {
|
function messageKey(message) {
|
||||||
const id = $("#tag-id").value;
|
return `${message.folder || state.folder}\u0000${message.uid}`;
|
||||||
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return;
|
}
|
||||||
try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
|
|
||||||
catch (error) { toast(error.message, "error"); }
|
function activeSearchHighlightTerms() {
|
||||||
|
if (!state.searchContext?.params) return [];
|
||||||
|
const params = new URLSearchParams(state.searchContext.params);
|
||||||
|
const terms = [];
|
||||||
|
for (const key of ["from", "to", "subject"]) {
|
||||||
|
const value = (params.get(key) || "").trim();
|
||||||
|
if (value) terms.push(value);
|
||||||
}
|
}
|
||||||
|
const query = (params.get("q") || "").trim();
|
||||||
async function promptTag(label, tags = state.tags) {
|
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
||||||
if (!tags.length) { toast("Create a label first", "error"); return null; }
|
for (const token of tokens) {
|
||||||
const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" });
|
const cleaned = token.replace(/^"|"$/g, "");
|
||||||
return tags.find((tag) => String(tag.id) === String(answer)) || null;
|
const colon = cleaned.indexOf(":");
|
||||||
|
if (colon > 0) {
|
||||||
|
const op = cleaned.slice(0, colon).toLowerCase();
|
||||||
|
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
|
||||||
|
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
|
||||||
|
} else if (cleaned.length >= 2) {
|
||||||
|
terms.push(cleaned);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
async function addTagToMessage(message, tag) {
|
function renderHighlightedText(node, value, terms) {
|
||||||
const folder = message.folder || state.folder;
|
const text = String(value || "");
|
||||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) });
|
node.replaceChildren();
|
||||||
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
|
if (!terms.length || !text) {
|
||||||
|
node.textContent = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lower = text.toLocaleLowerCase();
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < text.length) {
|
||||||
|
let matchIndex = -1;
|
||||||
|
let matchTerm = "";
|
||||||
|
for (const term of terms) {
|
||||||
|
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
|
||||||
|
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
|
||||||
|
matchIndex = index;
|
||||||
|
matchTerm = term;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchIndex < 0) {
|
||||||
|
node.append(document.createTextNode(text.slice(offset)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
|
||||||
|
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
|
||||||
|
node.append(mark);
|
||||||
|
offset = matchIndex + matchTerm.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedMessages() {
|
||||||
|
const source = state.searchAllResults || state.messages;
|
||||||
|
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBulkToolbar() {
|
||||||
|
const selected = selectedMessages();
|
||||||
|
const toolbar = $("#bulk-toolbar");
|
||||||
|
toolbar.classList.toggle("hidden", !state.selectionMode);
|
||||||
|
$("#bulk-count").textContent = `${selected.length} selected`;
|
||||||
|
const selectionUniverse = state.searchAllResults || state.messages;
|
||||||
|
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
|
||||||
|
$("#bulk-select-all").checked = allLoaded;
|
||||||
|
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
|
||||||
|
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
|
||||||
|
const selectSearch = $("#bulk-select-search");
|
||||||
|
if (selectSearch) {
|
||||||
|
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
|
||||||
|
selectSearch.disabled = !state.searchContext;
|
||||||
|
}
|
||||||
|
const junkButton = $("#bulk-junk");
|
||||||
|
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
|
||||||
|
const toggle = $("#select-mode-btn");
|
||||||
|
if (toggle) {
|
||||||
|
toggle.classList.toggle("active", state.selectionMode);
|
||||||
|
toggle.setAttribute("aria-pressed", String(state.selectionMode));
|
||||||
|
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
|
||||||
|
toggle.setAttribute("aria-label", toggle.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectionMode(enabled) {
|
||||||
|
state.selectionMode = Boolean(enabled);
|
||||||
|
if (!state.selectionMode) {
|
||||||
|
state.selectedMessages.clear();
|
||||||
|
state.searchAllResults = null;
|
||||||
|
}
|
||||||
|
renderMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMessageSelected(message, selected) {
|
||||||
|
if (!state.selectionMode) state.selectionMode = true;
|
||||||
|
const key = messageKey(message);
|
||||||
|
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
|
||||||
|
renderMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBulkSelection() {
|
||||||
|
state.selectedMessages.clear();
|
||||||
|
state.searchAllResults = null;
|
||||||
|
renderMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAllSearchResults() {
|
||||||
|
if (!state.searchContext) return;
|
||||||
|
const button = $("#bulk-select-search");
|
||||||
|
setBusy(button, true, "Selecting…");
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(state.searchContext.params);
|
||||||
|
params.set("all", "true");
|
||||||
|
params.delete("offset");
|
||||||
|
const data = await request(`/search?${params.toString()}`, { method: "GET" });
|
||||||
|
state.searchAllResults = data.results || [];
|
||||||
|
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
|
||||||
|
state.total = state.searchAllResults.length;
|
||||||
|
renderMessages();
|
||||||
|
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(button, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTagOptions() {
|
||||||
|
const select = $("#bulk-tag-select");
|
||||||
|
if (!select) return;
|
||||||
|
select.replaceChildren(new Option("Add label…", ""));
|
||||||
|
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTags() {
|
||||||
|
const list = $("#tag-list");
|
||||||
|
if (!list) return;
|
||||||
|
list.replaceChildren();
|
||||||
|
for (const tag of state.tags) {
|
||||||
|
const button = el("button", "tag-item");
|
||||||
|
button.type = "button";
|
||||||
|
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
|
||||||
|
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
|
||||||
|
button.addEventListener("click", () => loadTagMessages(tag));
|
||||||
|
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
|
||||||
|
list.append(button);
|
||||||
|
}
|
||||||
|
renderTagOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
try {
|
||||||
|
const data = await request("/tags", { method: "GET" });
|
||||||
|
state.tags = data.tags || [];
|
||||||
|
renderTags();
|
||||||
|
} catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTagMessages(tag) {
|
||||||
|
try {
|
||||||
|
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
|
||||||
|
state.messages = data.messages || [];
|
||||||
|
state.total = data.total_count || state.messages.length;
|
||||||
|
state.selectedMessages.clear();
|
||||||
|
clearActiveSearchContext();
|
||||||
|
renderMessages();
|
||||||
|
$("#folder-title").textContent = `Label: ${tag.name}`;
|
||||||
|
$("#message-count").textContent = `${state.total} results`;
|
||||||
|
} catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTagDialog(tag = null) {
|
||||||
|
$("#tag-id").value = tag?.id || "";
|
||||||
|
$("#tag-name").value = tag?.name || "";
|
||||||
|
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280";
|
||||||
|
$("#tag-delete").disabled = !tag;
|
||||||
|
$("#tag-dialog").showModal();
|
||||||
|
setTimeout(() => $("#tag-name").focus(), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTag(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = $("#tag-id").value;
|
||||||
|
const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value };
|
||||||
|
if (!payload.name) return;
|
||||||
|
try {
|
||||||
|
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
|
||||||
|
$("#tag-dialog").close();
|
||||||
|
await loadTags();
|
||||||
|
toast("Label saved");
|
||||||
|
} catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTag() {
|
||||||
|
const id = $("#tag-id").value;
|
||||||
|
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return;
|
||||||
|
try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
|
||||||
|
catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptTag(label, tags = state.tags) {
|
||||||
|
if (!tags.length) { toast("Create a label first", "error"); return null; }
|
||||||
|
const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" });
|
||||||
|
return tags.find((tag) => String(tag.id) === String(answer)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTagToMessage(message, tag) {
|
||||||
|
const folder = message.folder || state.folder;
|
||||||
|
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) });
|
||||||
|
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
|
||||||
|
renderMessages();
|
||||||
|
await loadTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTagFromMessage(message, tag) {
|
||||||
|
const folder = message.folder || state.folder;
|
||||||
|
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
|
||||||
|
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
|
||||||
|
renderMessages();
|
||||||
|
await loadTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkAddTag(tagId) {
|
||||||
|
const items = selectedMessages();
|
||||||
|
const tag = state.tags.find((item) => item.id === tagId);
|
||||||
|
if (!items.length || !tag) return;
|
||||||
|
try {
|
||||||
|
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) });
|
||||||
|
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
|
||||||
renderMessages();
|
renderMessages();
|
||||||
await loadTags();
|
await loadTags();
|
||||||
}
|
toast(`Label “${tag.name}” added`);
|
||||||
|
} catch (error) { toast(error.message, "error"); }
|
||||||
async function removeTagFromMessage(message, tag) {
|
}
|
||||||
const folder = message.folder || state.folder;
|
|
||||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
|
|
||||||
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
|
|
||||||
renderMessages();
|
|
||||||
await loadTags();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bulkAddTag(tagId) {
|
|
||||||
const items = selectedMessages();
|
|
||||||
const tag = state.tags.find((item) => item.id === tagId);
|
|
||||||
if (!items.length || !tag) return;
|
|
||||||
try {
|
|
||||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) });
|
|
||||||
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
|
|
||||||
renderMessages();
|
|
||||||
await loadTags();
|
|
||||||
toast(`Label “${tag.name}” added`);
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* RustBox UI module: 40-messages-reader.js */
|
|
||||||
async function loadMessages(reset = false, options = {}) {
|
async function loadMessages(reset = false, options = {}) {
|
||||||
const list = $("#message-list");
|
const list = $("#message-list");
|
||||||
const restoreState = Boolean(options.restoreState);
|
const restoreState = Boolean(options.restoreState);
|
||||||
@@ -117,7 +117,17 @@ function renderMessages() {
|
|||||||
flagButton.setAttribute("aria-label", flagButton.title);
|
flagButton.setAttribute("aria-label", flagButton.title);
|
||||||
flagButton.innerHTML = svgIcon("star");
|
flagButton.innerHTML = svgIcon("star");
|
||||||
flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); });
|
flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); });
|
||||||
quick.append(readButton, flagButton);
|
const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
|
||||||
|
fullscreenButton.type = "button";
|
||||||
|
fullscreenButton.title = "Open full screen";
|
||||||
|
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
|
||||||
|
fullscreenButton.innerHTML = svgIcon("external-link");
|
||||||
|
fullscreenButton.addEventListener("click", async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
await openMessage(folder, message.uid);
|
||||||
|
enterReaderFullscreen();
|
||||||
|
});
|
||||||
|
quick.append(readButton, flagButton, fullscreenButton);
|
||||||
icons.append(quick);
|
icons.append(quick);
|
||||||
if (Array.isArray(message.tags) && message.tags.length) {
|
if (Array.isArray(message.tags) && message.tags.length) {
|
||||||
const wrap = el("span", "message-tags");
|
const wrap = el("span", "message-tags");
|
||||||
@@ -177,6 +187,84 @@ async function senderIsSavedContact(email) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function readerFullscreenUrl(detail = state.currentDetail) {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
if (detail?.folder && Number.isFinite(Number(detail.uid))) {
|
||||||
|
url.searchParams.set("mail_folder", detail.folder);
|
||||||
|
url.searchParams.set("mail_uid", String(detail.uid));
|
||||||
|
url.searchParams.set("mail_fullscreen", "1");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreReaderScroll() {
|
||||||
|
const reader = $("#reader-content");
|
||||||
|
if (!reader) return;
|
||||||
|
const top = Math.max(0, Number(state.readerScrollTopBeforeFullscreen) || 0);
|
||||||
|
requestAnimationFrame(() => { reader.scrollTop = top; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReaderFullscreen(enabled, options = {}) {
|
||||||
|
const reader = $("#reader");
|
||||||
|
const button = $("#reader-fullscreen");
|
||||||
|
if (!reader || !button) return;
|
||||||
|
const next = Boolean(enabled);
|
||||||
|
if (next === reader.classList.contains("reader-fullscreen")) return;
|
||||||
|
if (next) {
|
||||||
|
state.readerScrollTopBeforeFullscreen = $("#reader-content")?.scrollTop || 0;
|
||||||
|
reader.classList.add("reader-fullscreen");
|
||||||
|
document.body.classList.add("reader-fullscreen-open");
|
||||||
|
button.setAttribute("aria-pressed", "true");
|
||||||
|
button.title = "Exit full screen";
|
||||||
|
button.setAttribute("aria-label", "Exit message full screen");
|
||||||
|
if (options.pushHistory !== false && state.currentDetail) {
|
||||||
|
history.pushState({ rustboxMailFullscreen: true, folder: state.currentDetail.folder, uid: state.currentDetail.uid }, "", readerFullscreenUrl());
|
||||||
|
state.readerFullscreenHistory = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reader.classList.remove("reader-fullscreen");
|
||||||
|
document.body.classList.remove("reader-fullscreen-open");
|
||||||
|
button.setAttribute("aria-pressed", "false");
|
||||||
|
button.title = "Open full screen";
|
||||||
|
button.setAttribute("aria-label", "Open message full screen");
|
||||||
|
state.readerFullscreenHistory = false;
|
||||||
|
if (options.keepUrl !== true && new URL(location.href).searchParams.get("mail_fullscreen") === "1") {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.searchParams.delete("mail_fullscreen");
|
||||||
|
history.replaceState(history.state && history.state.rustboxMailFullscreen ? null : history.state, "", url);
|
||||||
|
}
|
||||||
|
restoreReaderScroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterReaderFullscreen() {
|
||||||
|
if (!state.currentDetail) return;
|
||||||
|
setReaderFullscreen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitReaderFullscreen() {
|
||||||
|
if (!$("#reader")?.classList.contains("reader-fullscreen")) return;
|
||||||
|
if (state.readerFullscreenHistory) history.back();
|
||||||
|
else setReaderFullscreen(false, { pushHistory: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReaderFullscreen() {
|
||||||
|
if ($("#reader")?.classList.contains("reader-fullscreen")) exitReaderFullscreen();
|
||||||
|
else enterReaderFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMessageTarget(accountId, folder, uid, fullscreen = false) {
|
||||||
|
if (accountId && accountId !== state.accountId) await switchAccount(accountId);
|
||||||
|
if (folder && folder !== state.folder) {
|
||||||
|
state.folder = folder;
|
||||||
|
renderFolders();
|
||||||
|
await loadMessages(true);
|
||||||
|
}
|
||||||
|
if (folder && Number.isFinite(Number(uid))) await openMessage(folder, Number(uid));
|
||||||
|
if (fullscreen && state.currentDetail) setReaderFullscreen(true, { pushHistory: false });
|
||||||
|
}
|
||||||
|
|
||||||
async function openMessage(folder, uid) {
|
async function openMessage(folder, uid) {
|
||||||
clearTimeout(state.markReadTimer);
|
clearTimeout(state.markReadTimer);
|
||||||
state.markReadTimer = null;
|
state.markReadTimer = null;
|
||||||
@@ -756,6 +844,7 @@ async function importVcardAttachment(folder, uid, attachment) {
|
|||||||
function closeReader() {
|
function closeReader() {
|
||||||
clearTimeout(state.markReadTimer);
|
clearTimeout(state.markReadTimer);
|
||||||
state.markReadTimer = null;
|
state.markReadTimer = null;
|
||||||
|
if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false });
|
||||||
$("#reader").classList.remove("mobile-open");
|
$("#reader").classList.remove("mobile-open");
|
||||||
$("#reader-empty").classList.remove("hidden");
|
$("#reader-empty").classList.remove("hidden");
|
||||||
$("#reader-content").classList.add("hidden");
|
$("#reader-content").classList.add("hidden");
|
||||||
|
|||||||
+36
-25
@@ -1,4 +1,4 @@
|
|||||||
/* RustBox UI module: 50-compose.js */
|
|
||||||
function parseRecipients(raw) {
|
function parseRecipients(raw) {
|
||||||
return raw.split(",").map((value) => value.trim()).filter(Boolean);
|
return raw.split(",").map((value) => value.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ function estimatedMimeAttachmentBytes(rawBytes) {
|
|||||||
|
|
||||||
function estimateComposeMessageBytes(extraFiles = []) {
|
function estimateComposeMessageBytes(extraFiles = []) {
|
||||||
const editor = $("#compose-editor");
|
const editor = $("#compose-editor");
|
||||||
let bytes = 4096; // envelope headers, MIME boundaries and normal metadata
|
let bytes = 4096;
|
||||||
for (const selector of ["#compose-to", "#compose-cc", "#compose-bcc", "#compose-subject"]) bytes += utf8Bytes($(selector)?.value);
|
for (const selector of ["#compose-to", "#compose-cc", "#compose-bcc", "#compose-subject"]) bytes += utf8Bytes($(selector)?.value);
|
||||||
if (editor) {
|
if (editor) {
|
||||||
const plainBytes = utf8Bytes(editor.innerText);
|
const plainBytes = utf8Bytes(editor.innerText);
|
||||||
@@ -404,27 +404,37 @@ function setComposeUploadProgress(visible, percent = 0, label = trText("Uploadin
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadComposeFiles(files, options = {}) {
|
async function uploadComposeFiles(files, options = {}) {
|
||||||
const list = [...(files || [])];
|
const incoming = [...(files || [])];
|
||||||
const announce = options.announce !== false;
|
if (!incoming.length) return [];
|
||||||
|
if (state.composeUploading) return [];
|
||||||
|
const existing = new Set(state.composeAttachments.map((attachment) => `${String(attachment.filename || "")}\u0000${Number(attachment.size || 0)}`));
|
||||||
|
const selected = new Set();
|
||||||
|
const list = incoming.filter((file) => {
|
||||||
|
const key = `${String(file.name || "")}\u0000${Number(file.size || 0)}`;
|
||||||
|
if (existing.has(key) || selected.has(key)) return false;
|
||||||
|
selected.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
if (!list.length) return [];
|
if (!list.length) return [];
|
||||||
if (state.composeUploading) { if (announce) toast(trText("Another upload is already in progress"), "warning"); return []; }
|
|
||||||
const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024));
|
const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024));
|
||||||
const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024)));
|
const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024)));
|
||||||
const oversized = list.find((file) => Number(file.size || 0) > maxBytes);
|
const oversized = list.find((file) => Number(file.size || 0) > maxBytes);
|
||||||
if (oversized) {
|
if (oversized) {
|
||||||
toast(`${oversized.name}: ${trText("maximum attachment size is")} ${maxMiB} MiB`, "error");
|
setComposeUploadProgress(true, 0, `${oversized.name}: ${trText("maximum attachment size is")} ${maxMiB} MiB`);
|
||||||
|
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4200);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const maxMessageBytes = Math.max(1, Number(root.dataset.maxMessageBytes || 50 * 1024 * 1024));
|
const maxMessageBytes = Math.max(1, Number(root.dataset.maxMessageBytes || 50 * 1024 * 1024));
|
||||||
const maxMessageMiB = Math.max(1, Number(root.dataset.maxMessageMb || Math.round(maxMessageBytes / 1024 / 1024)));
|
const maxMessageMiB = Math.max(1, Number(root.dataset.maxMessageMb || Math.round(maxMessageBytes / 1024 / 1024)));
|
||||||
const estimatedAfterUpload = updateComposeMessageSize(list);
|
const estimatedAfterUpload = updateComposeMessageSize(list);
|
||||||
if (estimatedAfterUpload > maxMessageBytes) {
|
if (estimatedAfterUpload > maxMessageBytes) {
|
||||||
toast(`${trText("Estimated message size exceeds the configured limit")} (${formatComposeBytes(estimatedAfterUpload)} / ${maxMessageMiB} MiB). ${trText("The exact size will be checked before sending.")}`, "warning");
|
setComposeUploadProgress(true, 0, `${trText("Estimated message size exceeds the configured limit")} (${formatComposeBytes(estimatedAfterUpload)} / ${maxMessageMiB} MiB)`);
|
||||||
}
|
}
|
||||||
state.composeUploading = true;
|
state.composeUploading = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
state.composeUploadController = controller;
|
||||||
const uploadLabel = list.length === 1 ? trText("Uploading attachment…") : trText("Uploading files…");
|
const uploadLabel = list.length === 1 ? trText("Uploading attachment…") : trText("Uploading files…");
|
||||||
setComposeUploadProgress(true, 0, uploadLabel);
|
setComposeUploadProgress(true, 0, uploadLabel);
|
||||||
const progress = progressToast(uploadLabel);
|
|
||||||
await saveCurrentDraft(true, true);
|
await saveCurrentDraft(true, true);
|
||||||
const id = ensureDraftId();
|
const id = ensureDraftId();
|
||||||
const added = [];
|
const added = [];
|
||||||
@@ -435,27 +445,28 @@ async function uploadComposeFiles(files, options = {}) {
|
|||||||
form.append("file", file, file.name);
|
form.append("file", file, file.name);
|
||||||
const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => {
|
const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => {
|
||||||
const overall = Math.round(((index + (percent / 100)) / list.length) * 100);
|
const overall = Math.round(((index + (percent / 100)) / list.length) * 100);
|
||||||
const label = `${trText("Uploading attachment…")} ${file.name}`;
|
setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`);
|
||||||
setComposeUploadProgress(true, overall, `${label} · ${overall}%`);
|
}, controller.signal);
|
||||||
progress.update(overall, label);
|
const uploaded = data.attachments || [];
|
||||||
});
|
added.push(...uploaded);
|
||||||
added.push(...(data.attachments || []));
|
state.composeAttachments.push(...uploaded);
|
||||||
|
renderComposeAttachments();
|
||||||
|
state.composeDirty = true;
|
||||||
|
state.composeNeedsRemoteSync = true;
|
||||||
|
storeLocalDraftSnapshot();
|
||||||
}
|
}
|
||||||
state.composeAttachments.push(...added);
|
|
||||||
renderComposeAttachments();
|
|
||||||
state.composeDirty = true;
|
|
||||||
state.composeNeedsRemoteSync = true;
|
|
||||||
storeLocalDraftSnapshot();
|
|
||||||
setComposeUploadProgress(true, 100, trText("Upload complete"));
|
setComposeUploadProgress(true, 100, trText("Upload complete"));
|
||||||
progress.complete(added.length === 1 ? trText("Attachment uploaded") : `${added.length} ${trText("attachments uploaded")}`);
|
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450);
|
||||||
if (announce) toast(`${added.length} attachment${added.length === 1 ? "" : "s"} added`, "success");
|
|
||||||
setTimeout(() => setComposeUploadProgress(false), 450);
|
|
||||||
return added;
|
return added;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setComposeUploadProgress(false);
|
if (error?.name === "AbortError") setComposeUploadProgress(false);
|
||||||
progress.fail(error.message);
|
else {
|
||||||
return [];
|
setComposeUploadProgress(true, 0, error?.message || trText("Upload failed"));
|
||||||
|
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4800);
|
||||||
|
}
|
||||||
|
return added;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (state.composeUploadController === controller) state.composeUploadController = null;
|
||||||
state.composeUploading = false;
|
state.composeUploading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -978,8 +989,8 @@ async function recoverDraft() {
|
|||||||
async function closeComposer() {
|
async function closeComposer() {
|
||||||
clearTimeout(state.draftSaveTimer);
|
clearTimeout(state.draftSaveTimer);
|
||||||
clearTimeout(state.inlineImageCleanupTimer);
|
clearTimeout(state.inlineImageCleanupTimer);
|
||||||
|
if (state.composeUploadController) state.composeUploadController.abort();
|
||||||
await cleanupUnusedInlineAttachments();
|
await cleanupUnusedInlineAttachments();
|
||||||
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
|
|
||||||
if (state.composeNeedsRemoteSync && draftHasContent()) {
|
if (state.composeNeedsRemoteSync && draftHasContent()) {
|
||||||
const saved = await saveCurrentDraft(true, true, true);
|
const saved = await saveCurrentDraft(true, true, true);
|
||||||
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
|
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* RustBox UI module: 60-mail-actions-search.js */
|
|
||||||
async function updateFlag(flag, add) {
|
async function updateFlag(flag, add) {
|
||||||
if (!state.currentDetail) return;
|
if (!state.currentDetail) return;
|
||||||
const detail = state.currentDetail;
|
const detail = state.currentDetail;
|
||||||
@@ -485,6 +485,7 @@ function openMessageContextMenu(x, y, message) {
|
|||||||
const flagged = flags.includes("\\Flagged");
|
const flagged = flags.includes("\\Flagged");
|
||||||
showContextMenu(x, y, [
|
showContextMenu(x, y, [
|
||||||
{ label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) },
|
{ label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) },
|
||||||
|
{ label: "Open full screen", action: async () => { await ensureMessageOpen(message); enterReaderFullscreen(); } },
|
||||||
{ label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } },
|
{ label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } },
|
||||||
{ label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } },
|
{ label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } },
|
||||||
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
|
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
|
||||||
|
|||||||
+274
-274
@@ -1,294 +1,294 @@
|
|||||||
/* RustBox UI module: 70-contacts.js */
|
|
||||||
async function openContacts() {
|
|
||||||
$("#contacts-dialog").showModal();
|
|
||||||
await loadContactGroups();
|
|
||||||
await loadContacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContactGroups() {
|
async function openContacts() {
|
||||||
try {
|
$("#contacts-dialog").showModal();
|
||||||
const data = await request("/contact-groups", { method: "GET" });
|
await loadContactGroups();
|
||||||
state.contactGroups = data.groups || [];
|
await loadContacts();
|
||||||
renderContactGroupFilter();
|
}
|
||||||
renderContactGroupMembership($("#contact-id").value || null, []);
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderContactGroupFilter() {
|
async function loadContactGroups() {
|
||||||
const select = $("#contact-group-filter");
|
try {
|
||||||
const current = select.value;
|
const data = await request("/contact-groups", { method: "GET" });
|
||||||
select.replaceChildren(new Option("All contacts", ""));
|
state.contactGroups = data.groups || [];
|
||||||
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
|
renderContactGroupFilter();
|
||||||
if ([...select.options].some((option) => option.value === current)) select.value = current;
|
renderContactGroupMembership($("#contact-id").value || null, []);
|
||||||
const hasGroup = Boolean(select.value);
|
} catch (error) { toast(error.message, "error"); }
|
||||||
$("#contact-group-rename").disabled = !hasGroup;
|
}
|
||||||
$("#contact-group-delete").disabled = !hasGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadContactExport() {
|
function renderContactGroupFilter() {
|
||||||
try {
|
const select = $("#contact-group-filter");
|
||||||
const headers = new Headers();
|
const current = select.value;
|
||||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
select.replaceChildren(new Option("All contacts", ""));
|
||||||
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
|
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
|
||||||
if (!response.ok) throw new Error(`Export failed (${response.status})`);
|
if ([...select.options].some((option) => option.value === current)) select.value = current;
|
||||||
const blob = await response.blob();
|
const hasGroup = Boolean(select.value);
|
||||||
const url = URL.createObjectURL(blob);
|
$("#contact-group-rename").disabled = !hasGroup;
|
||||||
const link = document.createElement("a");
|
$("#contact-group-delete").disabled = !hasGroup;
|
||||||
link.href = url;
|
}
|
||||||
link.download = "contacts.vcf";
|
|
||||||
document.body.append(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
||||||
toast("Calendar exported", "success");
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importContactVcard(files) {
|
async function downloadContactExport() {
|
||||||
const file = files?.[0];
|
try {
|
||||||
if (!file) return;
|
const headers = new Headers();
|
||||||
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
|
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||||
const form = new FormData();
|
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
|
||||||
form.append("file", file, file.name || "contacts.vcf");
|
if (!response.ok) throw new Error(`Export failed (${response.status})`);
|
||||||
try {
|
const blob = await response.blob();
|
||||||
const data = await request("/contacts/import", { method: "POST", body: form });
|
const url = URL.createObjectURL(blob);
|
||||||
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
|
const link = document.createElement("a");
|
||||||
await loadContacts($("#contact-search").value.trim());
|
link.href = url;
|
||||||
} catch (error) { toast(error.message, "error"); }
|
link.download = "contacts.vcf";
|
||||||
finally { $("#contact-import-file").value = ""; }
|
document.body.append(link);
|
||||||
}
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
toast("Calendar exported", "success");
|
||||||
|
} catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
async function showContactDuplicates() {
|
async function importContactVcard(files) {
|
||||||
try {
|
const file = files?.[0];
|
||||||
const data = await request("/contacts/duplicates", { method: "GET" });
|
if (!file) return;
|
||||||
const groups = data.duplicates || [];
|
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
|
||||||
const list = $("#contact-duplicate-list");
|
const form = new FormData();
|
||||||
list.replaceChildren();
|
form.append("file", file, file.name || "contacts.vcf");
|
||||||
if (!groups.length) {
|
try {
|
||||||
list.append(el("div", "empty-list", "No duplicate contacts found"));
|
const data = await request("/contacts/import", { method: "POST", body: form });
|
||||||
}
|
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
|
||||||
for (const group of groups) {
|
await loadContacts($("#contact-search").value.trim());
|
||||||
const card = el("section", "duplicate-contact-group");
|
} catch (error) { toast(error.message, "error"); }
|
||||||
card.append(el("strong", "", group.email));
|
finally { $("#contact-import-file").value = ""; }
|
||||||
const rows = el("div", "duplicate-contact-options");
|
}
|
||||||
for (const contact of group.contacts || []) {
|
|
||||||
const row = el("div", "duplicate-contact-row");
|
|
||||||
const summary = el("div", "duplicate-contact-summary");
|
|
||||||
summary.append(el("strong", "", contact.name || contact.email));
|
|
||||||
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
|
|
||||||
const keep = el("button", "button quiet", "Keep this");
|
|
||||||
keep.type = "button";
|
|
||||||
keep.addEventListener("click", async () => {
|
|
||||||
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
|
|
||||||
if (!sources.length) return;
|
|
||||||
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
|
|
||||||
try {
|
|
||||||
let target = contact;
|
|
||||||
for (const source of sources) {
|
|
||||||
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
|
|
||||||
}
|
|
||||||
toast("Contacts merged");
|
|
||||||
await loadContacts($("#contact-search").value.trim());
|
|
||||||
await showContactDuplicates();
|
|
||||||
editContact(target);
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
});
|
|
||||||
row.append(summary, keep); rows.append(row);
|
|
||||||
}
|
|
||||||
card.append(rows); list.append(card);
|
|
||||||
}
|
|
||||||
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContacts(query = "") {
|
async function showContactDuplicates() {
|
||||||
try {
|
try {
|
||||||
const groupId = $("#contact-group-filter")?.value || "";
|
const data = await request("/contacts/duplicates", { method: "GET" });
|
||||||
let contacts;
|
const groups = data.duplicates || [];
|
||||||
if (groupId) {
|
const list = $("#contact-duplicate-list");
|
||||||
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
|
|
||||||
contacts = data.members || [];
|
|
||||||
if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
|
|
||||||
} else {
|
|
||||||
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
|
|
||||||
contacts = data.contacts || [];
|
|
||||||
}
|
|
||||||
state.contacts = contacts;
|
|
||||||
renderContacts();
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createContactGroup() {
|
|
||||||
const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
|
|
||||||
if (!name?.trim()) return;
|
|
||||||
try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
|
|
||||||
catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renameContactGroup() {
|
|
||||||
const id = $("#contact-group-filter").value;
|
|
||||||
const group = state.contactGroups.find((item) => item.id === id);
|
|
||||||
if (!group) return;
|
|
||||||
const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
|
|
||||||
if (!name?.trim() || name.trim() === group.name) return;
|
|
||||||
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
|
|
||||||
catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteContactGroup() {
|
|
||||||
const id = $("#contact-group-filter").value;
|
|
||||||
const group = state.contactGroups.find((item) => item.id === id);
|
|
||||||
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
|
|
||||||
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
|
|
||||||
catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContactMembership(contactId) {
|
|
||||||
if (!contactId) return renderContactGroupMembership(null, []);
|
|
||||||
try {
|
|
||||||
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
|
|
||||||
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderContactGroupMembership(contactId, selectedIds) {
|
|
||||||
const list = $("#contact-group-membership");
|
|
||||||
if (!list) return;
|
|
||||||
const selected = new Set(selectedIds || []);
|
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
|
if (!groups.length) {
|
||||||
for (const group of state.contactGroups) {
|
list.append(el("div", "empty-list", "No duplicate contacts found"));
|
||||||
const label = el("label", "checkbox-row");
|
|
||||||
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
|
|
||||||
input.addEventListener("change", async () => {
|
|
||||||
try {
|
|
||||||
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
|
|
||||||
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
|
|
||||||
await loadContactGroups();
|
|
||||||
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
|
|
||||||
});
|
|
||||||
label.append(input, el("span", "", group.name)); list.append(label);
|
|
||||||
}
|
}
|
||||||
}
|
for (const group of groups) {
|
||||||
|
const card = el("section", "duplicate-contact-group");
|
||||||
function renderContacts() {
|
card.append(el("strong", "", group.email));
|
||||||
const list = $("#contact-list");
|
const rows = el("div", "duplicate-contact-options");
|
||||||
list.replaceChildren();
|
for (const contact of group.contacts || []) {
|
||||||
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
|
const row = el("div", "duplicate-contact-row");
|
||||||
for (const contact of state.contacts) {
|
const summary = el("div", "duplicate-contact-summary");
|
||||||
const button = el("button", "contact-item");
|
summary.append(el("strong", "", contact.name || contact.email));
|
||||||
button.type = "button";
|
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
|
||||||
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
|
const keep = el("button", "button quiet", "Keep this");
|
||||||
button.addEventListener("click", () => editContact(contact));
|
keep.type = "button";
|
||||||
list.append(button);
|
keep.addEventListener("click", async () => {
|
||||||
|
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
|
||||||
|
if (!sources.length) return;
|
||||||
|
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
|
||||||
|
try {
|
||||||
|
let target = contact;
|
||||||
|
for (const source of sources) {
|
||||||
|
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
|
||||||
|
}
|
||||||
|
toast("Contacts merged");
|
||||||
|
await loadContacts($("#contact-search").value.trim());
|
||||||
|
await showContactDuplicates();
|
||||||
|
editContact(target);
|
||||||
|
} catch (error) { toast(error.message, "error"); }
|
||||||
|
});
|
||||||
|
row.append(summary, keep); rows.append(row);
|
||||||
|
}
|
||||||
|
card.append(rows); list.append(card);
|
||||||
}
|
}
|
||||||
}
|
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
|
||||||
|
} catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
function editContact(contact = null) {
|
async function loadContacts(query = "") {
|
||||||
$("#contact-id").value = contact?.id || "";
|
try {
|
||||||
$("#contact-name").value = contact?.name || "";
|
const groupId = $("#contact-group-filter")?.value || "";
|
||||||
$("#contact-email").value = contact?.email || "";
|
let contacts;
|
||||||
$("#contact-emails").value = (contact?.emails || []).join("\n");
|
if (groupId) {
|
||||||
$("#contact-company").value = contact?.company || "";
|
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
|
||||||
$("#contact-phone").value = contact?.phone || "";
|
contacts = data.members || [];
|
||||||
$("#contact-phones").value = (contact?.phones || []).join("\n");
|
if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
|
||||||
$("#contact-job-title").value = contact?.job_title || "";
|
} else {
|
||||||
$("#contact-website").value = contact?.website || "";
|
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
|
||||||
$("#contact-birthday").value = contact?.birthday || "";
|
contacts = data.contacts || [];
|
||||||
$("#contact-address").value = contact?.address || "";
|
}
|
||||||
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
|
state.contacts = contacts;
|
||||||
$("#contact-notes").value = contact?.notes || "";
|
renderContacts();
|
||||||
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
|
} catch (error) { toast(error.message, "error"); }
|
||||||
$("#contact-delete").disabled = !contact;
|
}
|
||||||
$("#contact-compose").disabled = !contact?.email;
|
|
||||||
$("#contact-photo-upload").disabled = !contact?.id;
|
|
||||||
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
|
|
||||||
renderContactPhoto(contact);
|
|
||||||
loadContactMembership(contact?.id || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearContactPhotoObjectUrl() {
|
async function createContactGroup() {
|
||||||
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
|
const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
|
||||||
state.contactPhotoUrl = null;
|
if (!name?.trim()) return;
|
||||||
}
|
try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
|
||||||
|
catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
async function renderContactPhoto(contact) {
|
async function renameContactGroup() {
|
||||||
clearContactPhotoObjectUrl();
|
const id = $("#contact-group-filter").value;
|
||||||
const image = $("#contact-photo-preview");
|
const group = state.contactGroups.find((item) => item.id === id);
|
||||||
const placeholder = $("#contact-photo-placeholder");
|
if (!group) return;
|
||||||
image.classList.add("hidden");
|
const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
|
||||||
image.removeAttribute("src");
|
if (!name?.trim() || name.trim() === group.name) return;
|
||||||
placeholder.classList.remove("hidden");
|
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
|
||||||
if (!contact?.id || !contact?.has_photo) return;
|
catch (error) { toast(error.message, "error"); }
|
||||||
try {
|
}
|
||||||
const headers = new Headers();
|
|
||||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
|
||||||
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
|
|
||||||
if (!response.ok) return;
|
|
||||||
const blob = await response.blob();
|
|
||||||
state.contactPhotoUrl = URL.createObjectURL(blob);
|
|
||||||
image.src = state.contactPhotoUrl;
|
|
||||||
image.classList.remove("hidden");
|
|
||||||
placeholder.classList.add("hidden");
|
|
||||||
} catch (_) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadContactPhoto(files) {
|
async function deleteContactGroup() {
|
||||||
const id = $("#contact-id").value;
|
const id = $("#contact-group-filter").value;
|
||||||
const file = files?.[0];
|
const group = state.contactGroups.find((item) => item.id === id);
|
||||||
if (!id || !file) return;
|
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
|
||||||
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; }
|
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
|
||||||
const form = new FormData();
|
catch (error) { toast(error.message, "error"); }
|
||||||
form.append("file", file, file.name || "photo");
|
}
|
||||||
try {
|
|
||||||
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
|
|
||||||
const contact = state.contacts.find((item) => item.id === id);
|
|
||||||
if (contact) contact.has_photo = true;
|
|
||||||
$("#contact-photo-delete").disabled = false;
|
|
||||||
await renderContactPhoto({ id, has_photo: true });
|
|
||||||
toast("Contact photo saved");
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
finally { $("#contact-photo-file").value = ""; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteContactPhoto() {
|
async function loadContactMembership(contactId) {
|
||||||
const id = $("#contact-id").value;
|
if (!contactId) return renderContactGroupMembership(null, []);
|
||||||
if (!id) return;
|
try {
|
||||||
try {
|
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
|
||||||
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" });
|
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
|
||||||
const contact = state.contacts.find((item) => item.id === id);
|
} catch (error) { toast(error.message, "error"); }
|
||||||
if (contact) contact.has_photo = false;
|
}
|
||||||
$("#contact-photo-delete").disabled = true;
|
|
||||||
await renderContactPhoto({ id, has_photo: false });
|
|
||||||
toast("Contact photo removed");
|
|
||||||
} catch (error) { toast(error.message, "error"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveContact(event) {
|
function renderContactGroupMembership(contactId, selectedIds) {
|
||||||
event.preventDefault();
|
const list = $("#contact-group-membership");
|
||||||
const payload = {
|
if (!list) return;
|
||||||
id: $("#contact-id").value || null,
|
const selected = new Set(selectedIds || []);
|
||||||
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
|
list.replaceChildren();
|
||||||
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
|
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
|
||||||
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
|
for (const group of state.contactGroups) {
|
||||||
is_favorite: $("#contact-favorite").checked, source: "manual",
|
const label = el("label", "checkbox-row");
|
||||||
};
|
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
|
||||||
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
|
input.addEventListener("change", async () => {
|
||||||
catch (error) { toast(error.message, "error"); }
|
try {
|
||||||
|
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
|
||||||
|
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
|
||||||
|
await loadContactGroups();
|
||||||
|
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
|
||||||
|
});
|
||||||
|
label.append(input, el("span", "", group.name)); list.append(label);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteContact() {
|
function renderContacts() {
|
||||||
const id = $("#contact-id").value;
|
const list = $("#contact-list");
|
||||||
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return;
|
list.replaceChildren();
|
||||||
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
|
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
|
||||||
catch (error) { toast(error.message, "error"); }
|
for (const contact of state.contacts) {
|
||||||
|
const button = el("button", "contact-item");
|
||||||
|
button.type = "button";
|
||||||
|
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
|
||||||
|
button.addEventListener("click", () => editContact(contact));
|
||||||
|
list.append(button);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function composeToContact() {
|
function editContact(contact = null) {
|
||||||
const email = $("#contact-email").value.trim();
|
$("#contact-id").value = contact?.id || "";
|
||||||
if (!email) return;
|
$("#contact-name").value = contact?.name || "";
|
||||||
$("#contacts-dialog").close();
|
$("#contact-email").value = contact?.email || "";
|
||||||
openCompose("new");
|
$("#contact-emails").value = (contact?.emails || []).join("\n");
|
||||||
$("#compose-to").value = email;
|
$("#contact-company").value = contact?.company || "";
|
||||||
}
|
$("#contact-phone").value = contact?.phone || "";
|
||||||
|
$("#contact-phones").value = (contact?.phones || []).join("\n");
|
||||||
|
$("#contact-job-title").value = contact?.job_title || "";
|
||||||
|
$("#contact-website").value = contact?.website || "";
|
||||||
|
$("#contact-birthday").value = contact?.birthday || "";
|
||||||
|
$("#contact-address").value = contact?.address || "";
|
||||||
|
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
|
||||||
|
$("#contact-notes").value = contact?.notes || "";
|
||||||
|
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
|
||||||
|
$("#contact-delete").disabled = !contact;
|
||||||
|
$("#contact-compose").disabled = !contact?.email;
|
||||||
|
$("#contact-photo-upload").disabled = !contact?.id;
|
||||||
|
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
|
||||||
|
renderContactPhoto(contact);
|
||||||
|
loadContactMembership(contact?.id || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearContactPhotoObjectUrl() {
|
||||||
|
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
|
||||||
|
state.contactPhotoUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderContactPhoto(contact) {
|
||||||
|
clearContactPhotoObjectUrl();
|
||||||
|
const image = $("#contact-photo-preview");
|
||||||
|
const placeholder = $("#contact-photo-placeholder");
|
||||||
|
image.classList.add("hidden");
|
||||||
|
image.removeAttribute("src");
|
||||||
|
placeholder.classList.remove("hidden");
|
||||||
|
if (!contact?.id || !contact?.has_photo) return;
|
||||||
|
try {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||||
|
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
|
||||||
|
if (!response.ok) return;
|
||||||
|
const blob = await response.blob();
|
||||||
|
state.contactPhotoUrl = URL.createObjectURL(blob);
|
||||||
|
image.src = state.contactPhotoUrl;
|
||||||
|
image.classList.remove("hidden");
|
||||||
|
placeholder.classList.add("hidden");
|
||||||
|
} catch (_) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadContactPhoto(files) {
|
||||||
|
const id = $("#contact-id").value;
|
||||||
|
const file = files?.[0];
|
||||||
|
if (!id || !file) return;
|
||||||
|
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; }
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file, file.name || "photo");
|
||||||
|
try {
|
||||||
|
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
|
||||||
|
const contact = state.contacts.find((item) => item.id === id);
|
||||||
|
if (contact) contact.has_photo = true;
|
||||||
|
$("#contact-photo-delete").disabled = false;
|
||||||
|
await renderContactPhoto({ id, has_photo: true });
|
||||||
|
toast("Contact photo saved");
|
||||||
|
} catch (error) { toast(error.message, "error"); }
|
||||||
|
finally { $("#contact-photo-file").value = ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteContactPhoto() {
|
||||||
|
const id = $("#contact-id").value;
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" });
|
||||||
|
const contact = state.contacts.find((item) => item.id === id);
|
||||||
|
if (contact) contact.has_photo = false;
|
||||||
|
$("#contact-photo-delete").disabled = true;
|
||||||
|
await renderContactPhoto({ id, has_photo: false });
|
||||||
|
toast("Contact photo removed");
|
||||||
|
} catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveContact(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
id: $("#contact-id").value || null,
|
||||||
|
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
|
||||||
|
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
|
||||||
|
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
|
||||||
|
is_favorite: $("#contact-favorite").checked, source: "manual",
|
||||||
|
};
|
||||||
|
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
|
||||||
|
catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteContact() {
|
||||||
|
const id = $("#contact-id").value;
|
||||||
|
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return;
|
||||||
|
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
|
||||||
|
catch (error) { toast(error.message, "error"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeToContact() {
|
||||||
|
const email = $("#contact-email").value.trim();
|
||||||
|
if (!email) return;
|
||||||
|
$("#contacts-dialog").close();
|
||||||
|
openCompose("new");
|
||||||
|
$("#compose-to").value = email;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* RustBox UI module: 71-calendar.js */
|
|
||||||
function startOfCalendarDay(date) {
|
function startOfCalendarDay(date) {
|
||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user