commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# RustBox environment overrides. TOML is preferred; these variables override it.
|
||||
HOST=127.0.0.1
|
||||
PORT=3001
|
||||
ENVIRONMENT=development
|
||||
DATA_DIR=.dev/data
|
||||
SESSION_TIMEOUT_HOURS=24
|
||||
|
||||
# Optional fixed mail hosts. Leave empty to provide them at login.
|
||||
IMAP_HOST=
|
||||
IMAP_PORT=993
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
TLS_ENABLED=true
|
||||
|
||||
# ManageSieve
|
||||
SIEVE_HOST=
|
||||
SIEVE_PORT=4190
|
||||
SIEVE_TLS_MODE=starttls
|
||||
SIEVE_ALLOW_INSECURE_AUTH=false
|
||||
SIEVE_TIMEOUT_SECS=10
|
||||
|
||||
TRUSTED_PROXIES=127.0.0.1,::1
|
||||
RUST_LOG=info
|
||||
# RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml
|
||||
@@ -0,0 +1,29 @@
|
||||
name: RustBox CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
rustbox:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: .
|
||||
- name: Reject TypeScript sources
|
||||
run: '! find . -type f \( -name "*.ts" -o -name "*.tsx" \) -print -quit | grep -q .'
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Test
|
||||
run: cargo test --locked
|
||||
- name: Clippy
|
||||
run: cargo clippy --locked --all-targets -- -D warnings
|
||||
- name: Build RustBox release
|
||||
run: cargo build --release --locked
|
||||
- name: Version
|
||||
run: ./target/release/rustbox --version
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Rust build
|
||||
**/target/
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
|
||||
# RustBox local runtime
|
||||
/.dev/
|
||||
/data/
|
||||
*.log
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local tooling
|
||||
.agent/
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
debugging/
|
||||
|
||||
*.zip
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
|
||||
### 0.0.1
|
||||
|
||||
- Moved Sieve rule creation/editing into a dedicated wide modal with the existing live “what will happen” preview; Settings now shows the rule list and launches the editor instead of squeezing the full builder into the settings pane.
|
||||
- Moved identity/signature editing into a separate viewport-safe modal and eliminated the horizontal overflow caused by the two-column Settings layout.
|
||||
- Added a persistent full-screen composer toggle.
|
||||
- Collapsed the OpenPGP composer controls into a compact summary row with expandable details, while keeping PGP options hidden when no OpenPGP keyring is configured.
|
||||
- Kept the signature image/CID flow from the previous UI patch in the packaged sources and synchronized standalone `ui/` assets with embedded `src/web/`.
|
||||
- Moved bulk message actions into a floating overlay so selection mode no longer consumes the message-list grid row or creates horizontal layout spill.
|
||||
- Hardened narrow reader/message toolbars and composer layout against controls colliding at split-pane widths.
|
||||
- Reworked composer address/options rows into distinct fields and allowed the formatting toolbar to wrap cleanly.
|
||||
- Expanded identity signatures/footers with text + HTML editing and image import/paste; imported images are materialized as inline CID draft attachments when composing.
|
||||
- Completed the OpenPGP roadmap lifecycle with private-key passphrase change. The existing rPGP implementation remains responsible for key parsing, signing, encryption, verification and decryption; GnuPG is used only to re-wrap an existing secret key when changing its passphrase. Passphrases are exchanged over stdin/status-fd and are never stored in the database or command line.
|
||||
- Added `POST /pgp/keys/{fingerprint}/change-passphrase` and a keyring UI action, including an explicit warning before removing passphrase protection.
|
||||
- Added optional `RUSTBOX_PGP_GPG_BINARY` override for deployments where the GnuPG binary is not named `gpg`.
|
||||
- Expanded the visual Sieve builder with dynamically addable conditions and actions. Additional conditions can be chained with nested AND/OR and capability checks now include dynamically added actions.
|
||||
- Replaced the stale roadmap gap list with a counted functional checklist: 45 functional items remain after this snapshot, with security/integration/acceptance gates tracked separately.
|
||||
# RustBox changelog
|
||||
- Fork base: Oxi `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`.
|
||||
- Flattened the Rust application to the repository root: `Cargo.toml`, `src/`, `migrations/`, `tests/` and `examples/`.
|
||||
- Removed the separate Oxi frontend source and all TypeScript/React/Next.js/Node/Bun build requirements.
|
||||
- Added a RustBox-owned embedded HTML/CSS/vanilla-JavaScript interface with dark/light/system themes.
|
||||
- Added `dev.sh`; ordinary `cargo run`, `cargo test` and `cargo build --release` now work from repository root.
|
||||
- Added mail reader/list/compose/search UI and About/Credits with the consumed Oxi revision embedded from `UPSTREAM_BASE`.
|
||||
- Added ManageSieve API/editor with STARTTLS, implicit TLS and explicit plaintext opt-in.
|
||||
- Added native systemd packaging and Cargo-only CI/release build.
|
||||
- Added Oxi tracking tools that map upstream `backend/...` changes into the flat RustBox tree without importing Oxi frontend sources.
|
||||
- Added full-baseline sync review plus selective single-commit backend porting from Oxi.
|
||||
- Build fix: corrected the embedded SVG raw-string delimiter, imported Figment's `Format` trait for `Toml::file`, and removed an unused `SieveCapabilities` re-export.
|
||||
- Mail server endpoints are now configuration-owned; the login form only accepts email, password and remember-session preference.
|
||||
- Fixed Inbox/folder/quota/draft operations to use the connection profile stored in the authenticated session instead of re-reading an optional global IMAP host.
|
||||
- Added startup validation for `imap_host` to prevent successful Sieve/login state with a non-functional Inbox.
|
||||
- Rebuilt the interface as a compact three-pane webmail layout with embedded SVG icons, HTML compose formatting, threads, folder actions, Sieve filters and appearance preferences.
|
||||
- Moved browser sources to `src/web/`; Cargo `build.rs` bundles modular CSS and the executable embeds all UI assets.
|
||||
- Removed gradients and Unicode pseudo-icons from the application interface.
|
||||
- Added nested-folder indentation, Reply all, accurate IMAP connection state, and RustBox-branded HttpOnly session/browser cookies.
|
||||
- Added persistent per-user appearance personalization stored in SQLite.
|
||||
- Added EN/PL language assets embedded in the Rust binary.
|
||||
- Added theme palette presets (sage/blue/slate/amber/violet/rose/custom) and custom accent color.
|
||||
- Added font family/size, message row height, pane widths, border radius, contrast and reduced-motion controls.
|
||||
- Added toggles for avatars, attachment indicators and flags.
|
||||
- Added message and folder context menus for common webmail actions.
|
||||
- Added raw-header viewer and clipboard copy.
|
||||
- Added folder subscribe/unsubscribe UI and context actions for creating subfolders.
|
||||
- Added identities/signatures settings UI and composer From identity selection.
|
||||
- Signatures from the selected identity are inserted into the composer.
|
||||
- Replaced the internal roadmap with the user-approved 0.0.1 plan.
|
||||
- Added per-account special IMAP folders for Sent/Drafts/Trash/Junk/Archive and wired them into send/delete/archive/junk flows.
|
||||
- Added IMAP copy, mark-all-read, empty-folder, multi-select and bulk operations.
|
||||
- Added drag-and-drop message-to-folder moves and previous/next reader navigation with keyboard shortcuts.
|
||||
- Added advanced and saved search, read/flag operators, user labels/tags and mailbox quota display.
|
||||
- Expanded composer to HTML/plain/Markdown modes, inline CID images, attachment reminder and a larger formatting toolbar.
|
||||
- Expanded identities with Reply-To, automatic Bcc, Organization metadata and text/HTML signature behavior.
|
||||
- Added contact groups and vCard import/export UI.
|
||||
- Added a capability-aware visual Sieve rule builder plus vacation/autoresponder generation and server validation.
|
||||
- Added ICS calendar import UI.
|
||||
- Connected the browser to the existing IMAP IDLE/WebSocket event stream for realtime refresh and desktop notifications.
|
||||
- Added notification Do Not Disturb hours and persisted per-folder notification preferences.
|
||||
- Expanded personalization presets and added custom background, panel and text colors in addition to the accent color.
|
||||
- Added persisted mailbox preferences for page size, date sort, delayed mark-read, threaded/flat reader mode, HTML/plain preference, quote limiting, snippet visibility and remote-image policy.
|
||||
- Added remote-image prompting with sandboxed message rendering and no-referrer loading.
|
||||
- Added IMAP capability diagnostics and a settings diagnostics panel for IMAP, ManageSieve and realtime WebSocket state.
|
||||
- Added multi-account UI for adding, switching and removing accounts in the current browser session.
|
||||
- Added user-selectable date and 12/24-hour time formats.
|
||||
- Added server-side validation for message-list page size/sort and mailbox preference values.
|
||||
- Added `.eml` import into any selected IMAP folder using APPEND, followed by cache invalidation and realtime folder refresh.
|
||||
- Added `List-Unsubscribe` handling for safe HTTPS/mailto targets from message headers.
|
||||
- Added in-app full RFC822 source viewer, Message-ID/sender copy actions and folder-properties inspection.
|
||||
- Raised extractor body limits only on upload endpoints so the documented attachment/vCard/ICS/.eml limits are actually reachable.
|
||||
- Expanded contacts with phone, job title, address, website and birthday fields.
|
||||
- Added contact photo upload/remove/display for PNG/JPEG/WebP with byte-signature validation and a 2 MB limit.
|
||||
- Extended vCard import/export with TEL, TITLE, ADR, URL and BDAY.
|
||||
- Changed contact persistence from SQLite `INSERT OR REPLACE` to conflict-update semantics so editing a contact does not destroy group/photo relations through a row replacement.
|
||||
- Fixed the calendar delete-handler syntax regression found during pre-release static review.
|
||||
- Added Tantivy index diagnostics and an in-app rebuild from the per-user SQLite cache.
|
||||
- Added IMAP request-pool reconnect/verification from Diagnostics.
|
||||
- Added active-session inventory across browsers while the process is running, User-Agent display, individual revoke and revoke-all-others.
|
||||
- Expanded calendar events with all-day, recurrence RRULE, attendees, organizer, status, reminder, meeting URL and color fields in the UI; added full local ICS export.
|
||||
- Added direct ICS/vCard import actions for message attachments.
|
||||
- Expanded contacts to multiple e-mail addresses, phone numbers and postal addresses.
|
||||
- Added duplicate-contact detection and transactional merge preserving groups, alternate values and contact photos.
|
||||
- Validated all 28 SQLite migrations from an empty database plus browser JS/JSON/TOML/shell assets.
|
||||
- Added Month, Week, Day and Agenda calendar views with persistent view selection and event-color markers.
|
||||
- Hardened contact/calendar/header/source dialogs against viewport overflow and UI scaling.
|
||||
- Fixed UI scaling so it also changes icon sizing and field/button spacing, and added persistent draggable pane splitters.
|
||||
- Added reusable action toasts to additional folder, search, calendar and ManageSieve operations.
|
||||
- Added Spam / Not spam actions in the primary and bulk message toolbars.
|
||||
- Added safe attachment Open/Download context actions and browser-side Download all as ZIP with UTF-8 filenames.
|
||||
- Added To/Cc/Bcc autocomplete using contacts and known addresses, including keyboard navigation.
|
||||
- Added message-list quick actions for read/unread and flag/unflag.
|
||||
- Hardened message rendering against RustBox-owned icon sprite markup leaking from malformed MIME content.
|
||||
- Kept the UI free of gradients, TypeScript, React/Next and Node/Bun runtime dependencies.
|
||||
- Added collapsible hierarchical IMAP folder branches with per-account remembered expansion state and leaf-name rendering.
|
||||
- Added a keyboard-shortcuts reference dialog and `!` shortcut for Spam / Not spam.
|
||||
- Added direct sender actions from the reader header: compose, add contact, copy address, search and create filter.
|
||||
- Expanded message sorting to date, sender, subject and size in ascending/descending order, available directly from the message-list toolbar and persisted per account.
|
||||
- Added the `Contacts only` remote-image policy, which verifies the current sender against the saved address book before allowing external images.
|
||||
- Expanded the visual Sieve builder with a second condition, AND/OR combination and per-condition negation while retaining server-side CHECKSCRIPT validation.
|
||||
- Added automatic identity selection on reply/forward using To/Cc and Delivered-To/Envelope-To headers before signature insertion.
|
||||
- Added mailbox quota warning/danger states at 80%/95% usage.
|
||||
- Added collapsible thread navigation with remembered expansion state.
|
||||
- Added safe Undo for MOVE-backed message actions when the destination UID can be resolved unambiguously, including reverse-order bulk Undo.
|
||||
- Added “Select all results” for search bulk actions by materializing the full cached match set on explicit request.
|
||||
- Hardened thread matching so `References` uses complete Message-ID tokens instead of accepting substring collisions.
|
||||
- Moved safe attachment previews into an in-app modal for images, PDFs and text attachments.
|
||||
- Added composer upload progress and clipboard image paste as CID inline attachments.
|
||||
- Added draft recovery from both server-side local drafts and an emergency browser snapshot; SMTP failures force a recoverable draft save.
|
||||
- Changed autosave so it no longer appends a fresh IMAP Drafts message every 1.2 seconds; IMAP snapshots are reserved for deliberate close/failure points until remote draft UID replacement is implemented.
|
||||
- Expanded keyboard shortcuts with `C` compose, `V` selection mode and `X` select/unselect current message.
|
||||
- Replaced runtime browser `confirm()`/`prompt()` flows in the main RustBox UI with reusable application dialogs, including folder/label choices used by message context actions and quick-filter creation.
|
||||
- Added an explicit message selection mode; row checkboxes are hidden/disabled outside that mode and bulk controls remain available while selecting.
|
||||
- Fixed compact/comfortable density so it changes control, toolbar and dialog sizing independently from UI scale and the user-defined message-row height.
|
||||
- Added visible toast auto-dismiss countdowns with pause-on-hover/focus behavior.
|
||||
- Added Compact, Balanced, Comfortable and Reading interface presets that persist through the existing display-preferences API.
|
||||
- Reworked message printing to use a sandboxed in-page print frame instead of a popup, while preserving sanitized HTML/plain-text message rendering and message metadata.
|
||||
- Added persisted composer priority and MDN/read-receipt options; outgoing optional headers are shared by SMTP delivery, draft snapshots and the IMAP Sent copy, with mailbox validation before SMTP.
|
||||
- Added RFC 8058 one-click unsubscribe through a backend HTTPS POST path with no redirects, DNS/IP public-address checks and direct-address TLS connection to reduce SSRF/DNS-rebinding risk.
|
||||
- Fixed migration registration so the existing V028 contact migration and new V029 draft-send-options migration are both executed; the migration set is now V001–V029.
|
||||
- Expanded the visual Sieve builder to three conditions with nested AND/OR grouping, two actions, capability-driven action disabling, managed visual-rule ordering, JSON backup/restore and active-script overwrite confirmation.
|
||||
- Added optional scheduled vacation start/end dates using Sieve `currentdate` with `date` + `relational` requirements when the server advertises those extensions.
|
||||
- Added a browser-wide default Spam/Junk destination with a per-account special-folder override; mail actions fall back to IMAP `\Junk`/Spam auto-detection when neither resolves.
|
||||
- Added persistent mailbox-level “flagged on top” and per-folder inherit/on/off overrides. Flagged-first ordering is performed in SQLite before pagination.
|
||||
- Added a subtle red lower-edge marker to flagged message rows without changing the existing star action.
|
||||
- Replaced the quick-filter folder prompt/raw-editor handoff with a dedicated ManageSieve filter wizard that previews the rule, respects advertised extensions, validates with CHECKSCRIPT, saves directly to the selected script and can activate it.
|
||||
- Kept an Advanced editor handoff that preloads the visual Sieve builder for users who want to refine the generated rule.
|
||||
- Added SQLite migration V030 and a folder-preferences API for per-folder list behavior.
|
||||
- Added aggregate unread counts to the browser title and supported application badges.
|
||||
- Added `/api/ready` alongside liveness `/api/health`; readiness checks that the local RustBox data directory is accessible without coupling process readiness to account-specific IMAP/SMTP availability.
|
||||
- Made generic application dialogs scroll as a fallback when their content exceeds the browser workspace, while retaining independent scroll regions for structured composer/settings/contact/calendar/source dialogs.
|
||||
- Fixed inline-image compose preview: uploaded CID images render from the authenticated draft-attachment endpoint while editing, then serialize back to `cid:` references for draft persistence and SMTP/IMAP MIME generation.
|
||||
- Removing an inline-image attachment now also removes its matching image node from the composer instead of leaving a broken placeholder.
|
||||
- Replaced the About-page Runtime row with `Author: @linuxiarz.pl`.
|
||||
- Extended the quick-filter wizard with an optional capability-aware “also mark as read” action and an explicit notice that Sieve rules apply to new incoming mail rather than retroactively changing existing messages.
|
||||
- Changed the About author label to `@linuxiarz.pl`.
|
||||
- Fixed action-toast controls so Undo and the close button share one compact control group; clicks no longer propagate between the two actions.
|
||||
- Preserved the active folder, loaded-page depth, message-list scroll position and active search context across normal refresh/reconnect flows within the browser session.
|
||||
- Added an explicit offline/reconnecting state and bounded automatic retry for idempotent GET/HEAD requests only; mutating operations are never automatically replayed.
|
||||
- Added authenticated SMTP connection diagnostics, IMAP custom-keyword capability detection and local synchronization-cache diagnostics with last folder/message sync timestamps.
|
||||
- Added friendly active-session client names such as `Firefox · Linux` while keeping the full User-Agent available as hover detail.
|
||||
- Added client-side highlighting of active search terms in sender, subject and snippet without injecting result text as HTML.
|
||||
- Fixed the Settings modal grid so the header occupies only its natural height and the sidebar/content area fills the remaining workspace instead of being pushed toward the bottom for short panels such as About.
|
||||
- Made Settings navigation/content independently scrollable and mobile tabs horizontally scrollable.
|
||||
- Added inline-image selection/editing in the composer: display width, inline/left/center/right placement and in-app crop/cut with a live preview. Cropping creates a replacement CID attachment; GIF cropping stays disabled to preserve animation.
|
||||
- Dragged image files now insert as inline CID images in HTML mode instead of silently becoming ordinary attachments.
|
||||
- Added cleanup of CID attachments whose inline image was removed from the editor, including a final cleanup before send/close.
|
||||
- Added a warning before switching away from HTML when rich formatting may be lost.
|
||||
- Added persisted Composing settings for default format, draft autosave interval, default priority, default MDN/read-receipt request and attachment-reminder behavior.
|
||||
- Added SQLite migration V031 for the new composing preferences.
|
||||
- Added SQLite migrations V032–V036 for advanced appearance preferences, draft compose-format persistence, optional IMAP-keyword synchronization, composer behavior preferences and remote IMAP-draft tracking.
|
||||
- Expanded Appearance with icon size, toolbar/sidebar density, reader/composer widths, selection color, unread styling, compact headers, border/scrollbar controls, monochrome mode and per-pane background overrides.
|
||||
- Completed the safe Markdown compose pipeline: Markdown source remains the plain alternative while preview/delivery uses escaped generated HTML rather than raw Markdown HTML.
|
||||
- Added persisted composer defaults for font, reply-above/below-quote, quote style, forward inline/as-attachment and browser spellcheck.
|
||||
- Added administrator-configurable outgoing headers with header-name validation, structural/MIME-header denylisting and CR/LF-injection rejection.
|
||||
- Added opt-in native IMAP keyword synchronization for RustBox labels. Stable RustBox keywords are written only where persistent custom keywords are supported and observed RustBox keywords are imported back into local labels; full remote-removal reconciliation remains partial.
|
||||
- Reworked deliberate IMAP Drafts snapshots so RustBox resolves the newly appended draft UID from UIDNEXT plus an exact `X-RustBox-Draft-ID` search, stores UID/UIDVALIDITY and removes the prior snapshot only after an unambiguous safe match.
|
||||
- Added strict draft-ID validation before attachment-path use, closing traversal through draft identifiers and making the draft marker safe for IMAP headers.
|
||||
- Changed attachment uploads to stream multipart chunks into bounded `.part` files with atomic rename, stale-part cleanup and cleanup on interruption/error instead of buffering the entire upload in memory.
|
||||
- Fixed the duplicated draft-route `Extension(config)` extractor and kept standalone `ui/` assets synchronized with embedded `src/web/` sources.
|
||||
- Fixed the Axum 0.8 `E0277 Handler` failure in tag/bulk-tag routes by separating synchronous SQLite work from awaited IMAP work; no rusqlite connection/transaction is retained across network awaits.
|
||||
- Applied the same DB/network phase separation to single-tag, untag and tag-delete handlers so the compile failure does not simply move to the next route.
|
||||
- Added authoritative IMAP-label reconciliation for folders that explicitly support persistent custom keywords. Remote RustBox keyword additions and removals can now be reconciled into local labels from Mailbox settings.
|
||||
- Fixed `Keep me signed in` so the server-side session timeout is extended together with the cookie instead of still expiring at the normal default timeout.
|
||||
- Active-session diagnostics now expose effective expiry and whether the session uses the remembered timeout.
|
||||
- Added graceful SIGTERM/Ctrl+C shutdown and explicit cancellation of dedicated IMAP IDLE tasks before process exit.
|
||||
- Extended synchronization diagnostics with active IMAP IDLE task/subscriber counts to make reconnect storms and leaked subscriptions visible from the UI.
|
||||
Generated
+4768
File diff suppressed because it is too large.
Load diff
+46
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "rustbox"
|
||||
version = "0.0.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
default-run = "rustbox"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
figment = { version = "0.10", features = ["env", "toml"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
tower = "0.5"
|
||||
dashmap = "6"
|
||||
async-imap = { version = "0.10", default-features = false, features = ["runtime-tokio"] }
|
||||
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
|
||||
tower_governor = "0.8"
|
||||
governor = "0.10"
|
||||
ipnet = "2.11"
|
||||
rand = "0.9"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
rusqlite = { version = "0.34", features = ["bundled"] }
|
||||
refinery = { version = "0.9", features = ["rusqlite"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||
async-trait = "0.1.92"
|
||||
mail-parser = "0.11.2"
|
||||
futures = "0.3.32"
|
||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-native-tls", "smtp-transport", "builder"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tantivy = "0.22"
|
||||
ical = "0.11"
|
||||
regex = "1.12.3"
|
||||
url = "2.5"
|
||||
pgp = { version = "0.21", default-features = false }
|
||||
rand08 = { package = "rand", version = "0.8.6" }
|
||||
smallvec = "1"
|
||||
sha1 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1"
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,13 @@
|
||||
# RustBox attribution / notice
|
||||
|
||||
RustBox 0.0.1 is derived from the Oxi webmail project:
|
||||
|
||||
- Upstream: `c0h1b4/oxi`
|
||||
- Base snapshot: `7f1507c486b78cf33e54beb701dd460d3bd2bd5d`
|
||||
- RustBox modifications: single-process Rust runtime, RustBox-owned embedded interface, native service packaging, configuration changes and ManageSieve integration.
|
||||
|
||||
RustBox does not ship the Oxi TypeScript frontend in this release. Existing Oxi Rust backend code remains the implementation base and should retain all attribution/license obligations applicable to the upstream project.
|
||||
|
||||
The user-supplied Oxi snapshot did **not** contain a `LICENSE` or `NOTICE` file. Upstream documentation identified Oxi as MIT-licensed, but this source release deliberately does not reconstruct an authoritative license text. Before public redistribution, verify the license at the exact upstream revision and include all required notices.
|
||||
|
||||
Some internal compatibility identifiers originating from Oxi remain (for example cookie/database naming). They are implementation details retained to reduce unnecessary migration and merge breakage.
|
||||
@@ -0,0 +1,166 @@
|
||||
# RustBox 0.0.1
|
||||
|
||||
RustBox is a webmail derived from `c0h1b4/oxi`. The consumed Oxi revision is recorded in `UPSTREAM_BASE`.
|
||||
|
||||
The repository is one Cargo application. The RustBox browser interface lives under `src/web/`, uses HTML/CSS/vanilla JavaScript, and is embedded into the Rust executable at build time. There is no TypeScript, React, Next.js, Node.js or Bun build step.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
rustbox/
|
||||
├── Cargo.toml
|
||||
├── Cargo.lock
|
||||
├── build.rs
|
||||
├── src/
|
||||
│ ├── main.rs
|
||||
│ ├── ui.rs
|
||||
│ └── web/
|
||||
│ ├── index.html
|
||||
│ ├── app.js
|
||||
│ ├── icons.svg
|
||||
│ └── css/
|
||||
├── migrations/
|
||||
├── tests/
|
||||
├── examples/
|
||||
├── docs/
|
||||
├── packaging/
|
||||
├── scripts/
|
||||
├── config.dev.toml
|
||||
├── config.example.toml
|
||||
├── UPSTREAM_BASE
|
||||
└── dev.sh
|
||||
```
|
||||
|
||||
`build.rs` combines the CSS modules listed in `src/web/css/manifest.txt`. Cargo then embeds the CSS bundle, HTML, JavaScript and SVG sprite in the RustBox executable.
|
||||
|
||||
## Configure mail servers
|
||||
|
||||
Mail endpoints are configured by the administrator, not on the login screen. Users enter only email and password.
|
||||
|
||||
Edit `config.dev.toml` before the first run:
|
||||
|
||||
```toml
|
||||
imap_host = "mail.example.com"
|
||||
imap_port = 993
|
||||
smtp_host = "mail.example.com"
|
||||
smtp_port = 587
|
||||
tls_enabled = true
|
||||
|
||||
# Optional; defaults to imap_host.
|
||||
# sieve_host = "mail.example.com"
|
||||
sieve_port = 4190
|
||||
sieve_tls_mode = "starttls"
|
||||
sieve_allow_insecure_auth = false
|
||||
|
||||
# Optional administrator-owned headers added to every outgoing message.
|
||||
# Structural/MIME headers such as From, To, Subject and Content-Type are rejected.
|
||||
outgoing_headers = [
|
||||
"X-Company-Mailer: RustBox",
|
||||
]
|
||||
```
|
||||
|
||||
If `imap_host` is missing or blank, RustBox fails configuration validation at startup instead of letting login succeed into a broken Inbox state.
|
||||
|
||||
## Current 0.0.1 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
|
||||
|
||||
Requirements: Rust toolchain with Cargo. OpenPGP key generation/sign/encrypt/decrypt is implemented with rPGP. Changing the passphrase of an existing private key additionally requires GnuPG (`gpg`) on the server; override the executable with `RUSTBOX_PGP_GPG_BINARY` if needed.
|
||||
|
||||
```bash
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3001
|
||||
```
|
||||
|
||||
To use another configuration:
|
||||
|
||||
```bash
|
||||
RUSTBOX_CONFIG=/path/to/rustbox.toml ./dev.sh
|
||||
```
|
||||
|
||||
Normal Cargo commands work from the repository root:
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
cargo test
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
RustBox uses a compact three-pane mail layout: folders, message list and reader. The current internal baseline includes configurable special IMAP folders, collapsible folder hierarchy, folder CRUD/subscriptions, move/copy/bulk actions, drag-and-drop message moves, collapsible threads, raw headers/source download, labels, advanced/saved search, quota warnings, contacts/groups/vCard, calendar/ICS import, identities/signatures with automatic reply identity selection, HTML/plain/Markdown compose modes, CID inline images, and ManageSieve raw + visual rule editing with negation, dynamically addable conditions/actions and nested AND/OR chaining. OpenPGP includes per-user key management, identity binding, PGP/MIME + inline sign/encrypt/decrypt/verify, WKD/HKP discovery and private-key passphrase management.
|
||||
|
||||
IMAP IDLE events are delivered to the browser over the embedded WebSocket endpoint for realtime refresh and optional desktop notifications. Notification preferences include sound, folder selection and Do Not Disturb hours.
|
||||
|
||||
Appearance preferences are persisted per account and include light/dark/system theme, density, preview position, UI scale, multiple color presets, custom accent/background/panel/text colors, font, row height, pane widths, radius, contrast and visibility toggles. The mailbox toolbar also exposes persisted date/sender/subject/size sorting, and remote images can be restricted to saved contacts.
|
||||
|
||||
The interface intentionally uses flat surfaces, small radii and one SVG icon set. It contains no CSS gradients and no Unicode characters used as toolbar icons.
|
||||
|
||||
## ManageSieve
|
||||
|
||||
Supported modes:
|
||||
|
||||
- `starttls` — recommended;
|
||||
- `implicit` — TLS immediately after connect;
|
||||
- `none` — plaintext transport; reusable credential authentication remains blocked unless `sieve_allow_insecure_auth = true` is explicitly enabled.
|
||||
|
||||
When `sieve_host` is not configured, RustBox uses the authenticated session's IMAP host. Sieve uses the same session credentials; passwords are not exposed to the browser after login.
|
||||
|
||||
## Native installation
|
||||
|
||||
```bash
|
||||
./scripts/build-release.sh
|
||||
sudo ./scripts/install-systemd.sh
|
||||
sudo systemctl enable --now rustbox
|
||||
sudo journalctl -u rustbox -f
|
||||
```
|
||||
|
||||
For public access put an HTTPS reverse proxy in front of RustBox. The production example binds to `127.0.0.1:3001`.
|
||||
|
||||
## Following Oxi
|
||||
|
||||
Initialize Git after extracting the release archive:
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add .
|
||||
git commit -m "RustBox 0.0.1 baseline"
|
||||
./scripts/setup-upstream.sh
|
||||
```
|
||||
|
||||
Inspect newer Oxi changes:
|
||||
|
||||
```bash
|
||||
./scripts/oxi-status.sh
|
||||
```
|
||||
|
||||
Import the mapped Oxi backend delta:
|
||||
|
||||
```bash
|
||||
./scripts/sync-upstream.sh
|
||||
```
|
||||
|
||||
Oxi `backend/...` paths are mapped into RustBox's flat Cargo tree. Oxi frontend sources are never copied. Relevant upstream frontend behavior is reviewed and reimplemented in `src/web/`. `UPSTREAM_BASE` advances only after `./scripts/finish-upstream.sh` completes review and the build gate.
|
||||
|
||||
To port one backend commit without advancing the baseline:
|
||||
|
||||
```bash
|
||||
./scripts/oxi-port-commit.sh <oxi-commit>
|
||||
```
|
||||
|
||||
See `docs/UPSTREAM.md` for details.
|
||||
|
||||
## Attribution
|
||||
|
||||
See `NOTICE.md`. The supplied Oxi snapshot did not contain an authoritative `LICENSE` file; verify the exact upstream licensing terms before public redistribution.
|
||||
|
||||
## Internal 0.0.1 working scope
|
||||
|
||||
The authoritative internal feature plan is `docs/RUSTBOX_PLAN.md`. Current implementation status is tracked in `docs/IMPLEMENTATION_STATUS.md`. Version `0.0.1` remains pre-publication until the full acceptance criteria in the plan are met.
|
||||
@@ -0,0 +1,49 @@
|
||||
RustBox 0.0.1 source release
|
||||
|
||||
Runtime/source model:
|
||||
- one root Cargo package
|
||||
- src/ Rust application code
|
||||
- src/web/ RustBox-owned embedded browser UI
|
||||
- migrations/ SQLite migrations
|
||||
- no TypeScript/React/Next.js/Node.js/Bun build
|
||||
- ManageSieve TLS/STARTTLS/explicit insecure transport option
|
||||
- native systemd packaging
|
||||
|
||||
Oxi tracking:
|
||||
- UPSTREAM_BASE stores consumed Oxi revision
|
||||
- scripts/setup-upstream.sh configures upstream-oxi
|
||||
- scripts/oxi-status.sh reports upstream changes
|
||||
- scripts/sync-upstream.sh maps the cumulative Oxi backend delta into flat RustBox paths
|
||||
- scripts/finish-upstream.sh advances UPSTREAM_BASE only after explicit upstream review/build validation
|
||||
- scripts/oxi-port-commit.sh maps a selected Oxi backend commit without advancing the baseline
|
||||
- Oxi frontend changes are reviewed and manually ported to src/web/
|
||||
- docs/UI.md documents the browser UI/build conventions
|
||||
|
||||
Current 0.0.1 working snapshot additions:
|
||||
- persisted special folders and mailbox behavior preferences
|
||||
- bulk IMAP actions, copy/move, folder mark-all-read/empty, message-to-folder drag/drop
|
||||
- HTML/plain/Markdown composer modes, CID inline images and expanded identity/signature rules
|
||||
- advanced/saved search, tags, contact groups, visual Sieve/vacation builder
|
||||
- IMAP IDLE/WebSocket refresh, desktop notifications and DND
|
||||
- IMAP capabilities diagnostics and multi-account browser session UI
|
||||
- RFC822 .eml download/import, List-Unsubscribe handling and RFC 8058 one-click HTTPS unsubscribe
|
||||
- expanded contacts with multiple e-mails/phones/addresses, vCard profile fields, duplicate merge and validated PNG/JPEG/WebP photos
|
||||
- calendar Month/Week/Day/Agenda views, richer event metadata and ICS import/export
|
||||
- search-index status/rebuild, IMAP reconnect diagnostics and live cross-browser session revoke UI
|
||||
- 31 contiguous SQLite migrations wired sequentially and executed on a fresh SQLite database during static validation
|
||||
- safe Undo for resolvable IMAP MOVE actions and select-all across complete cached search results
|
||||
- exact-token thread matching and expanded keyboard selection/navigation shortcuts
|
||||
- in-app attachment preview, clipboard CID image paste and upload progress
|
||||
- recoverable drafts with browser emergency snapshots and throttled IMAP Drafts snapshots
|
||||
- outgoing priority and MDN/read-receipt draft/send options with pre-SMTP mailbox validation
|
||||
- expanded Sieve visual rules: nested three-condition grouping, two actions, managed reordering, backup/restore, active overwrite guard and optional date-scheduled vacation
|
||||
- spam destination default plus per-account override, pre-pagination flagged-first sorting with per-folder overrides, and subtle flagged-row markers
|
||||
- direct ManageSieve quick-filter wizard with validation/save/activation and advanced-editor handoff
|
||||
- unread browser/app badge updates and a public local-storage readiness endpoint
|
||||
- inline image editing with display sizing, alignment, crop/cut preview and CID cleanup
|
||||
- dedicated Composing settings for format, autosave interval, default priority, MDN and attachment reminder
|
||||
- corrected Settings header/content grid for short panels and scroll-safe navigation
|
||||
|
||||
Validation note:
|
||||
- JavaScript, JSON, TOML, SVG, shell syntax, UI selector/icon integrity and SQLite migrations were validated in the release environment.
|
||||
- A full cargo build/test was not executed in this environment because cargo/rustc are not installed; run ./dev.sh or cargo test on a Rust toolchain host before deployment.
|
||||
@@ -0,0 +1 @@
|
||||
7f1507c486b78cf33e54beb701dd460d3bd2bd5d
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::{env, fs, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
|
||||
let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
|
||||
let css_dir = root.join("src/web/css");
|
||||
let manifest = css_dir.join("manifest.txt");
|
||||
|
||||
println!("cargo:rerun-if-changed={}", manifest.display());
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
root.join("src/web/index.html").display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
root.join("src/web/app.js").display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
root.join("src/web/icons.svg").display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
root.join("src/web/i18n/en.json").display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
root.join("src/web/i18n/pl.json").display()
|
||||
);
|
||||
|
||||
let manifest_text = fs::read_to_string(&manifest).expect("read src/web/css/manifest.txt");
|
||||
let mut bundle = String::from("/* RustBox UI bundle - generated by build.rs */\n");
|
||||
|
||||
for raw in manifest_text.lines() {
|
||||
let name = raw.trim();
|
||||
if name.is_empty() || name.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
name.ends_with(".css")
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& !name.contains(".."),
|
||||
"invalid CSS manifest entry: {name}"
|
||||
);
|
||||
let path = css_dir.join(name);
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let css =
|
||||
fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
||||
bundle.push_str("\n/* ");
|
||||
bundle.push_str(name);
|
||||
bundle.push_str(" */\n");
|
||||
bundle.push_str(&css);
|
||||
bundle.push('\n');
|
||||
}
|
||||
|
||||
fs::write(out.join("rustbox-ui.css"), bundle).expect("write RustBox CSS bundle");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# RustBox local development configuration.
|
||||
host = "127.0.0.1"
|
||||
port = 3001
|
||||
environment = "development"
|
||||
data_dir = ".dev/data"
|
||||
session_timeout_hours = 24
|
||||
|
||||
# Mail server configuration is administrator-owned. The login screen only asks
|
||||
# for the user's email address and password.
|
||||
# Set these values before starting RustBox.
|
||||
imap_host = ""
|
||||
imap_port = 993
|
||||
smtp_host = ""
|
||||
smtp_port = 587
|
||||
tls_enabled = true
|
||||
|
||||
# ManageSieve. Leave sieve_host unset to use imap_host.
|
||||
sieve_port = 4190
|
||||
sieve_tls_mode = "starttls" # starttls | implicit | none
|
||||
sieve_allow_insecure_auth = false
|
||||
sieve_timeout_secs = 10
|
||||
|
||||
trusted_proxies = "127.0.0.1,::1"
|
||||
@@ -0,0 +1,44 @@
|
||||
# RustBox 0.0.1
|
||||
host = "127.0.0.1"
|
||||
port = 3001
|
||||
environment = "production"
|
||||
data_dir = "/var/lib/rustbox"
|
||||
session_timeout_hours = 24
|
||||
remember_me_enabled = true
|
||||
remember_me_days = 30
|
||||
|
||||
# Required mail profile. Users only enter email + password on the login page.
|
||||
imap_host = "mail.example.com"
|
||||
imap_port = 993
|
||||
smtp_host = "mail.example.com"
|
||||
smtp_port = 587
|
||||
tls_enabled = true
|
||||
|
||||
# Optional reverse-proxy subpath, e.g. "/mail".
|
||||
# base_path = "/mail"
|
||||
|
||||
# ManageSieve. If sieve_host is omitted, imap_host from the authenticated
|
||||
# session is used. Plaintext authentication stays blocked unless explicitly
|
||||
# enabled.
|
||||
# sieve_host = "mail.example.com"
|
||||
sieve_port = 4190
|
||||
sieve_tls_mode = "starttls" # starttls | implicit | none
|
||||
sieve_allow_insecure_auth = false
|
||||
sieve_timeout_secs = 10
|
||||
|
||||
trusted_proxies = "127.0.0.1,::1"
|
||||
|
||||
# Optional headers added to every outgoing message. Use `Header-Name: value`.
|
||||
# RustBox rejects structural/MIME header overrides and CR/LF injection.
|
||||
outgoing_headers = []
|
||||
# OpenPGP. Private keys are stored per user below data_dir with 0700/0600
|
||||
# permissions. Passphrases are never persisted. WKD is enabled by default;
|
||||
# HKP lookup is opt-in and HTTPS-only.
|
||||
pgp_wkd_enabled = true
|
||||
pgp_hkp_enabled = false
|
||||
pgp_hkp_url = "https://keys.openpgp.org"
|
||||
pgp_network_timeout_secs = 10
|
||||
pgp_max_key_bytes = 1048576
|
||||
# Private-key export is deliberately disabled unless the administrator opts in.
|
||||
pgp_allow_private_export = false
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "error: Rust/Cargo is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ROOT/.dev/data"
|
||||
export RUSTBOX_CONFIG="${RUSTBOX_CONFIG:-$ROOT/config.dev.toml}"
|
||||
export DATA_DIR="${DATA_DIR:-$ROOT/.dev/data}"
|
||||
export RUST_LOG="${RUST_LOG:-rustbox=debug,tower_http=info}"
|
||||
|
||||
if [ ! -f "$RUSTBOX_CONFIG" ]; then
|
||||
echo "error: config not found: $RUSTBOX_CONFIG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RustBox: http://127.0.0.1:3001"
|
||||
echo "Config: $RUSTBOX_CONFIG"
|
||||
echo "Data: $DATA_DIR"
|
||||
echo
|
||||
|
||||
cargo run --quiet --bin rustbox -- --check-config
|
||||
exec cargo run --bin rustbox -- "$@"
|
||||
@@ -0,0 +1,157 @@
|
||||
# RustBox 0.0.1 - implementation status
|
||||
|
||||
This file tracks the internal `0.0.1` baseline. The user-approved `docs/RUSTBOX_PLAN.md` remains authoritative. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly.
|
||||
|
||||
## Implemented / wired in this working snapshot
|
||||
|
||||
### Runtime and upstream
|
||||
|
||||
- Single Rust runtime with embedded HTML/CSS/vanilla-JavaScript UI.
|
||||
- No TypeScript, React, Next.js, Node.js or Bun product build/runtime.
|
||||
- Oxi upstream tracking and mapped backend-port scripts retained.
|
||||
- EN/PL language assets embedded in the binary.
|
||||
|
||||
### Mail and IMAP workflow
|
||||
|
||||
- IMAP folder list, hierarchy, create, create subfolder, rename, delete, subscribe/unsubscribe, plus collapsible branch navigation with per-account remembered expansion state.
|
||||
- Special folders stored per account: Sent, Drafts, Trash, Junk and Archive. Spam/Junk additionally supports a browser-wide default destination with a per-account override and IMAP special-use/name fallback.
|
||||
- Special-folder references follow folder renames and are cleared when a referenced folder is deleted.
|
||||
- Sent-copy, delete-to-Trash, Archive and Junk actions use the configured special folders.
|
||||
- Mark-all-read and empty-folder operations.
|
||||
- Message move and copy operations.
|
||||
- Multi-select and bulk read/unread, flag/unflag, archive, move, copy, label and delete actions, including explicit select-all across the complete cached search result set.
|
||||
- Drag-and-drop a message onto a folder to move it.
|
||||
- Previous/next message navigation plus keyboard actions for common reader operations, compose, selection mode and selecting the current message.
|
||||
- Message reading, collapsible thread navigation, attachments, raw headers, full RFC822 `.eml` download via `BODY.PEEK[]` and `.eml` import into a selected IMAP folder via APPEND.
|
||||
- Message/folder context menus with common Roundcube-style actions, including folder properties, full message source, Message-ID/address copy and mailing-list unsubscribe; the reader sender line also exposes compose/contact/search/filter actions.
|
||||
- Mailbox quota display where the IMAP server exposes quota, including warning/danger states at 80%/95%.
|
||||
- Per-account mailbox behavior: 20/50/100/200 messages per page, date/sender/subject/size sorting in both directions, configurable mark-read delay, threaded/flat reader mode, HTML/plain preference, remote-image policy including Contacts-only loading, quote limiting, optional list snippets and a default “flagged on top” mode. Each folder can inherit, force-enable or force-disable flagged-first ordering; sorting is applied before pagination.
|
||||
- IMAP capability diagnostics for IDLE, MOVE, UIDPLUS, CONDSTORE, QRESYNC, SORT, THREAD, NAMESPACE, SPECIAL-USE, QUOTA, ACL, LIST-EXTENDED and UTF8=ACCEPT.
|
||||
- Forward-as-attachment using `message/rfc822`.
|
||||
- Mailing-list unsubscribe action from `List-Unsubscribe`; RFC 8058 one-click HTTPS requests are sent by the backend only when `List-Unsubscribe-Post: List-Unsubscribe=One-Click` is advertised, with redirect-free public-address SSRF checks. Ordinary HTTPS/mailto links remain user-confirmed UI actions.
|
||||
- Explicit upload-body limits for attachments, contact imports/photos, ICS imports and `.eml` import so Axum does not reject allowed uploads at its smaller default limit.
|
||||
- Attachment context menu supports in-app preview/download for safe image/PDF/text MIME classes; multiple attachments can be downloaded as a client-built UTF-8 ZIP without external runtime dependencies.
|
||||
- Message rows expose compact hover/focus quick actions for read/unread and flag/unflag; flagged rows also receive a subtle red lower-edge marker.
|
||||
|
||||
### Composer / identities
|
||||
|
||||
- HTML, plain-text and Markdown compose modes. Markdown keeps its source text for the plain alternative and generates escaped/sanitized HTML for preview and delivery without accepting raw Markdown HTML.
|
||||
- Expanded HTML toolbar: bold, italic, underline, strike, lists, quote, alignment, links, font, size, color, clear formatting, undo/redo.
|
||||
- Draft autosave, explicit recovery of saved/browser-emergency drafts, and attachment upload/delete. Deliberate IMAP Drafts snapshots carry a stable RustBox draft identifier; the previous remote snapshot is removed only after the new UID is resolved unambiguously and UIDVALIDITY still matches.
|
||||
- Drag-and-drop attachments with visible upload progress. Attachment uploads stream multipart chunks into `.part` files with an enforced size limit, atomic final rename and stale/incomplete temporary-file cleanup instead of buffering the whole attachment in memory.
|
||||
- Inline image upload using CID attachments, including paste and drag/drop of PNG/JPEG/GIF/WebP images into the composer. During editing, CID images use authenticated same-origin draft-attachment preview URLs and are serialized back to `cid:` references for saved/sent MIME content. Inline images can be resized, aligned left/center/right/inline and cropped in-app (GIF cropping is intentionally disabled to preserve animation); display-only resizing does not rewrite the source file. Unused CID attachments are cleaned when their image is removed from the editor.
|
||||
- Attachment reminder for common English/Polish wording.
|
||||
- Multiple identities.
|
||||
- Identity Reply-To, automatic Bcc, Organization metadata, text/HTML signatures and per-action signature rules/position; replies/forwards auto-select the matching identity from To/Cc/Delivered-To/Envelope-To when possible.
|
||||
- Composer From identity selection and signature insertion.
|
||||
- Outgoing priority (normal/high/low) and MDN/read-receipt requests are persisted in drafts and emitted consistently in SMTP delivery and the IMAP Sent copy; all envelope/header mailboxes are parsed before SMTP delivery. Administrators can also configure validated additional outgoing headers; structural/MIME headers and CR/LF-injected values are rejected.
|
||||
|
||||
### Search, labels and contacts
|
||||
|
||||
- Tantivy/search backend retained.
|
||||
- Search operators including `from:`, `to:`, `subject:`, `folder:`, `has:attachment`, `is:read`, `is:unread`, `is:flagged`, `is:unflagged` and date filters. Active search terms are highlighted in sender, subject and snippet text using DOM text nodes rather than result HTML.
|
||||
- Advanced-search dialog and saved searches.
|
||||
- User labels/tags with validated HEX colors, message chips, context actions and bulk assignment. Optional native IMAP-keyword synchronization writes stable RustBox keywords only on folders advertising persistent custom-keyword support, imports observed RustBox keywords and provides an authoritative reconcile action that also applies remote removals only on confirmed keyword-capable folders.
|
||||
- Contacts CRUD/search with phone, job title, postal address, website and birthday fields.
|
||||
- Contact photo upload/remove/display (PNG/JPEG/WebP, 2 MB limit, MIME signature validation).
|
||||
- Contact groups, membership editing and group filtering.
|
||||
- vCard import/export includes the expanded profile fields and multiple e-mail/phone/address values.
|
||||
- Duplicate-contact detection checks primary and alternate e-mail addresses; merge keeps target data, alternate values, group memberships and a source photo when the target has none.
|
||||
- vCard attachments can be imported directly from a message attachment.
|
||||
- Composer To/Cc/Bcc fields use the existing contact/known-address autocomplete API with keyboard navigation.
|
||||
|
||||
### Sieve
|
||||
|
||||
- ManageSieve raw script list/get/put/delete/rename/activate/deactivate/check.
|
||||
- STARTTLS, implicit TLS and explicit plaintext opt-in.
|
||||
- Quick filters generated from sender/domain/subject use an in-app wizard with condition/action preview, capability-aware actions, optional mark-as-read chaining, target-script selection, server validation and direct save/activation; an Advanced editor handoff remains available. The wizard explicitly states that Sieve changes apply to future incoming messages.
|
||||
- Visual rule builder with From/domain/To/Subject/List-ID/custom-header conditions, three-condition nested AND/OR grouping and per-condition negation.
|
||||
- Visual actions: move, copy, redirect, discard, mark read, add IMAP flag and keep, with an optional second action.
|
||||
- RustBox-generated visual rules carry stable markers and can be reordered by drag/drop or keyboard-accessible up/down controls without rewriting unrelated raw-script text.
|
||||
- Builder merges `require` extensions, disables actions unsupported by advertised server capabilities and validates generated scripts with the server.
|
||||
- Autoresponder/vacation builder supports subject, body, reply interval, mailing-list/automated-mail suppression and optional inclusive start/end dates using `currentdate` when the server advertises `date` + `relational`.
|
||||
- Sieve scripts can be backed up/restored as a validated RustBox JSON bundle; restore validates all scripts before writing, and saving over an active script requires an explicit application confirmation.
|
||||
|
||||
### Calendar and realtime
|
||||
|
||||
- Calendar event CRUD UI with Month, Week, Day and Agenda views.
|
||||
- iCalendar/ICS import UI over the existing parser/backend, plus direct import from message attachments and full local ICS export.
|
||||
- Calendar event editor includes all-day mode, RRULE storage, attendees, organizer, status, reminder, meeting URL and color metadata.
|
||||
- Browser WebSocket client connected to the existing IMAP IDLE/event backend.
|
||||
- Realtime folder/message refresh for NewMessages/FlagsChanged/FolderUpdated events.
|
||||
- Desktop notifications, optional sound, per-folder selection and persisted Do Not Disturb hours. Aggregate unread count updates the browser-tab title and the application badge where the Badging API is available.
|
||||
- Account switch/remove/add workflow for multiple authenticated accounts in one browser session.
|
||||
- Active-session list across browsers while the RustBox process is running, with creation/last-active timestamps, friendly browser/device labels, full User-Agent hover detail, individual revoke and revoke-all-others.
|
||||
- Diagnostics panel exposing IMAP capabilities (including persistent custom-keyword support), authenticated SMTP connectivity, ManageSieve status, browser WebSocket state, local synchronization-cache timestamps/counts and live IMAP IDLE task/subscriber counts without returning server host names or credentials. Public liveness (`/api/health`) and local-storage readiness (`/api/ready`) endpoints are available for service supervision.
|
||||
- Search-index diagnostics expose cached/indexed counts, with a rebuild operation from the local SQLite cache.
|
||||
- IMAP reconnect control drops reusable request-path sessions and verifies a fresh authenticated connection.
|
||||
- Graceful SIGTERM/Ctrl+C shutdown stops accepting new requests and aborts dedicated IMAP IDLE tasks before process exit.
|
||||
|
||||
### Personalization
|
||||
|
||||
- Persistent per-user display preferences in SQLite.
|
||||
- Light/dark/system theme.
|
||||
- Presets: sage, blue, teal, cyan, indigo, slate, graphite, amber, orange, violet, rose, red and custom.
|
||||
- Custom accent, background, panel and text colors.
|
||||
- Font family and size, message row height, folder/message pane widths, border radius and contrast. Advanced controls include icon size, toolbar/sidebar density, reader/composer widths, selection color, unread font/badge styling, compact headers, border visibility, scrollbar density, monochrome mode and per-pane background overrides.
|
||||
- Date-format (locale/ISO/EU/US) and time-format (locale/24-hour/12-hour) preferences.
|
||||
- Compact/comfortable density, preview right/bottom/off, UI scale and reduced motion.
|
||||
- Avatar/attachment/flag visibility toggles.
|
||||
- Folder and message-list pane widths can also be adjusted directly with draggable splitters and persist through the existing per-user display preferences. Keyboard shortcuts are documented in an in-app reference dialog.
|
||||
|
||||
### Build/installation fixes in this snapshot
|
||||
|
||||
- Fixed the `E0499` mutable-borrow failure in `mark_all_read`: the `uid_store()` stream is dropped before `session.logout()`.
|
||||
- Fixed a duplicate `else` introduced in the calendar delete handler during the calendar expansion.
|
||||
- All 36 SQLite migrations are present, contiguous and wired sequentially in the migration runner during static validation; V032–V036 add advanced appearance controls, draft compose-format persistence, optional IMAP-keyword sync, composer behavior preferences and remote IMAP-draft tracking.
|
||||
|
||||
## Partially implemented - keep visible as incomplete
|
||||
|
||||
- Sieve visual builder now supports a three-condition nested AND/OR group, negation, two actions, capability-aware disabling, managed-rule ordering and scheduled vacation dates. An arbitrary-depth condition tree, arbitrary-length action stack and full capability-aware condition/action matrix remain unfinished.
|
||||
- Drafts are stored locally and can be recovered after interrupted editing/SMTP failure. Remote replacement is implemented conservatively: RustBox resolves the new UID with UIDNEXT plus an exact per-draft header search and only removes the previous snapshot when resolution and UIDVALIDITY are safe; wider real-server compatibility still needs the release gate.
|
||||
- Calendar is local/ICS-capable and stores recurrence/attendee/reminder metadata, but recurrence expansion/edit semantics, invitation responses and CalDAV synchronization are not finished.
|
||||
- Notifications are wired through IMAP IDLE/WebSocket, but richer per-account notification policy and service-worker/background delivery are not finished.
|
||||
- Identities emit Organization metadata; composer priority, MDN request headers and validated administrator-defined outbound headers are wired. SMTP DSN negotiation remains to be completed.
|
||||
|
||||
## Not yet complete - do not claim Roundcube parity
|
||||
|
||||
- MOVE-based Undo is available for move/delete-to-Trash/archive/spam when the destination UID can be resolved unambiguously from pre-MOVE UIDNEXT plus exact Message-ID. A persistent multi-step undo history and servers/messages without a safely resolvable destination UID remain unfinished.
|
||||
- Contact model supports multiple e-mails/phones/addresses and duplicate merge UI; CardDAV and LDAP synchronization/UI remain.
|
||||
- OpenPGP roadmap scope is implemented end-to-end: per-user keyring, generate/import/export/delete, passphrase test/change, identity binding, sign/encrypt/sign+encrypt, PGP/MIME + inline PGP, verify/decrypt, trust/fingerprint UI, WKD and optional HTTPS HKP. Real `cargo test` and interoperability regression coverage are still release-gate work.
|
||||
- TNEF/`winmail.dat` decoding and full SMTP DSN negotiation are still not implemented. SMTPUTF8/IDN behavior still requires the real-server compatibility matrix from the release plan.
|
||||
- IMAP ACL/shared-folder management and full capabilities settings UI remain the next major protocol block.
|
||||
- Spam backend adapters for rspamd/SpamAssassin/custom command.
|
||||
- Calendar Month/Week/Day/Agenda views are present; recurrence expansion/exceptions, invitation response workflow and CalDAV remain.
|
||||
- Full CardDAV synchronization.
|
||||
- Password-change adapters and persistent cross-restart session inventory/device metadata. Live cross-browser sessions can already be listed/revoked while the process is running.
|
||||
- Diagnostics now includes IMAP reconnect, SMTP connectivity, index rebuild and last local folder/message synchronization metadata; deeper background task/queue metrics and repair workflows remain.
|
||||
- Full security, acceptance and performance matrix from `docs/RUSTBOX_PLAN.md`.
|
||||
|
||||
## UI reliability / workflow fixes in the current snapshot
|
||||
|
||||
- Contact editor, calendar, raw-header and message-source dialogs use viewport-bounded layouts with independent scrolling and non-overlapping actions. Generic dialogs also fall back to whole-card scrolling when their content exceeds the browser workspace. Settings uses an explicit header + flexible-content grid so short panels no longer push the navigation/content block toward the bottom of the modal; settings navigation/content scroll independently when needed.
|
||||
- Month calendar grid and toolbar layout are responsive and remain usable under browser/UI scaling.
|
||||
- UI scale now affects font size, row height, pane widths, icons, field/button padding and control/header dimensions without being overwritten by the separate font-size setting.
|
||||
- Responsive layout keeps user-selected folder/message pane widths within safe viewport bounds instead of reverting to fixed widths.
|
||||
- Application actions have reusable success/error/warning/info toast feedback, including from within native modal dialogs.
|
||||
- Primary and bulk message toolbars include Spam / Not spam, driven by the configured Junk folder.
|
||||
- Reader includes Print, Source viewer and quick Create-filter-from-sender actions.
|
||||
- Header viewer shows a structured common-header summary and the raw header block.
|
||||
- vCard/iCalendar attachment SVG action icons no longer leak their markup as visible text; reader cleanup also strips only RustBox-owned icon-sprite fragments from malformed MIME content.
|
||||
- Message HTML receives an additional client-side sanitization pass before sandboxed iframe rendering; plain text is preserved exactly.
|
||||
- Embedded 0.0.1 UI assets are served with `Cache-Control: no-store` so development snapshots do not mix stale and current bundles.
|
||||
- Native browser confirmation/text prompts are no longer used by the main UI workflows; destructive actions and folder/label/text choices use RustBox application dialogs.
|
||||
- Message selection has an explicit mode: per-row checkboxes only appear while selecting, with select-all and bulk actions grouped in the selection toolbar.
|
||||
- Search selection can materialize and select every cached matching result, not only the currently rendered page.
|
||||
- Folder/message list state now preserves the last folder, loaded-page depth and scroll position; active searches also retain their query and scroll position during normal realtime/refresh flows.
|
||||
- Network failures surface an offline/reconnecting banner. Only idempotent GET/HEAD requests receive bounded automatic retries; send/move/delete and other mutating requests are never auto-replayed.
|
||||
- Toasts with actions keep Undo and the close button adjacent in a dedicated action group so closing a toast cannot trigger Undo.
|
||||
- MOVE-based actions expose a toast Undo only when the backend can identify the server-assigned destination UID without guessing; bulk Undo reverses resolved moves in reverse order.
|
||||
- Thread lookup now validates complete References Message-ID tokens after the SQL candidate query, preventing partial-ID false matches.
|
||||
- Safe attachment previews stay inside a RustBox modal instead of opening a browser popup.
|
||||
- Composer uploads expose progress, clipboard images become CID inline attachments, and browser-local recovery preserves edits when a draft save fails.
|
||||
- Compact/comfortable density now visibly affects controls/toolbars/dialog headers without overwriting the separate row-height and pane-width preferences.
|
||||
- Toasts show the remaining auto-dismiss time and pause their countdown while hovered or keyboard-focused.
|
||||
- Appearance settings include Compact, Balanced, Comfortable and Reading layout presets persisted through display preferences.
|
||||
- Composing settings now persist the default format, autosave interval, default priority, default read-receipt request, attachment-reminder behavior, default font, reply placement, quote style, forward mode and browser spellcheck through display preferences.
|
||||
- Message Print uses an in-page print frame instead of a popup and prints sanitized HTML or plain text with From/To/Date metadata.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Releasing RustBox
|
||||
|
||||
1. Update the version in `Cargo.toml` and the RustBox package entry in `Cargo.lock`.
|
||||
2. Confirm `UPSTREAM_BASE` is the Oxi revision actually consumed by the release.
|
||||
3. Ensure no TypeScript sources or separate frontend build dependencies are present.
|
||||
4. Run:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test --locked
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
./scripts/build-release.sh
|
||||
```
|
||||
|
||||
5. Verify:
|
||||
|
||||
```bash
|
||||
./target/release/rustbox --version
|
||||
RUSTBOX_CONFIG=./config.dev.toml ./target/release/rustbox --check-config
|
||||
```
|
||||
|
||||
6. Package the repository without `target/`, `.dev/` or `.git/`.
|
||||
7. Generate SHA-256 sums for release archives.
|
||||
@@ -0,0 +1,107 @@
|
||||
# RustBox 0.0.1 — 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.1.
|
||||
- [ ] 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.
|
||||
File diff suppressed because it is too large.
Load diff
+21
@@ -0,0 +1,21 @@
|
||||
# RustBox UI
|
||||
|
||||
RustBox owns its browser interface under `src/web/`.
|
||||
|
||||
Design direction:
|
||||
|
||||
- mail-first three-pane layout: folders, message list, message reader;
|
||||
- compact typography and spacing, small radii, flat surfaces and restrained color use;
|
||||
- dark, light and system themes;
|
||||
- compact/comfortable density, preview-pane position and UI scale preferences;
|
||||
- a single embedded SVG icon sprite for interface actions;
|
||||
- no CSS gradients and no Unicode pseudo-icons;
|
||||
- HTML compose formatting, threaded reading, folder operations and ManageSieve filters.
|
||||
|
||||
The supplied RustProxy source was used as a reference for disciplined asset organization, modular CSS, compact typography and Cargo-driven UI assembly. RustBox does not depend on RustProxy at runtime and retains its own mail-oriented layout and components.
|
||||
|
||||
## Build
|
||||
|
||||
`build.rs` reads `src/web/css/manifest.txt`, concatenates the CSS modules in the declared order and writes the bundle to Cargo `OUT_DIR`. `src/ui.rs` embeds that bundle together with `index.html`, `app.js` and `icons.svg` into the RustBox executable.
|
||||
|
||||
No Node.js, Bun, TypeScript, React or Next.js build step exists.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Oxi upstream policy
|
||||
|
||||
RustBox is derived from `c0h1b4/oxi`. `UPSTREAM_BASE` contains the exact Oxi commit whose backend changes have been consumed by RustBox.
|
||||
|
||||
RustBox uses a flat Cargo layout, while Oxi uses a `backend/` subtree. The sync tooling therefore treats Oxi as a source upstream and maps its backend paths into RustBox rather than reintroducing the Oxi directory layout.
|
||||
|
||||
## Path mapping
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
Oxi backend/Cargo.toml -> RustBox Cargo.toml
|
||||
Oxi backend/Cargo.lock -> RustBox Cargo.lock
|
||||
Oxi backend/rust-toolchain.toml -> RustBox rust-toolchain.toml
|
||||
Oxi backend/src/... -> RustBox src/...
|
||||
Oxi backend/migrations/... -> RustBox migrations/...
|
||||
Oxi backend/tests/... -> RustBox tests/...
|
||||
Oxi backend/examples/... -> RustBox examples/...
|
||||
Oxi backend/docs/... -> RustBox docs/...
|
||||
```
|
||||
|
||||
RustBox-owned `src/web/` has no automatic mapping from the Oxi frontend.
|
||||
|
||||
## Configure the upstream
|
||||
|
||||
A release archive does not contain `.git`. Initialize the RustBox repository once:
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add .
|
||||
git commit -m "RustBox 0.0.1 baseline"
|
||||
./scripts/setup-upstream.sh
|
||||
```
|
||||
|
||||
The script adds:
|
||||
|
||||
```text
|
||||
upstream-oxi -> https://github.com/c0h1b4/oxi.git
|
||||
```
|
||||
|
||||
and fetches Oxi history/tags.
|
||||
|
||||
## Inspect a newer Oxi state
|
||||
|
||||
```bash
|
||||
./scripts/oxi-status.sh
|
||||
```
|
||||
|
||||
or inspect a specific Oxi ref:
|
||||
|
||||
```bash
|
||||
./scripts/oxi-status.sh upstream-oxi/main
|
||||
./scripts/oxi-status.sh <tag-or-commit>
|
||||
```
|
||||
|
||||
The report separates:
|
||||
|
||||
1. Oxi `backend/` changes that can be mapped into RustBox;
|
||||
2. Oxi `frontend/` changes that require a manual UI port;
|
||||
3. other Oxi root-level changes that require manual review;
|
||||
4. upstream commits since `UPSTREAM_BASE`.
|
||||
|
||||
## Advance the consumed Oxi backend state
|
||||
|
||||
```bash
|
||||
./scripts/sync-upstream.sh
|
||||
```
|
||||
|
||||
The operation:
|
||||
|
||||
1. requires a clean RustBox Git worktree;
|
||||
2. fetches `upstream-oxi`;
|
||||
3. verifies that the target descends from the recorded `UPSTREAM_BASE`;
|
||||
4. creates a `sync/oxi-*` review branch;
|
||||
5. generates the Oxi backend delta from `UPSTREAM_BASE` to the target;
|
||||
6. rewrites only patch metadata so `backend/<path>` becomes `<path>`;
|
||||
7. applies the mapped delta with Git three-way merge support;
|
||||
8. does not import Oxi frontend sources;
|
||||
9. records the target as a pending sync inside Git metadata;
|
||||
10. deliberately leaves `UPSTREAM_BASE` unchanged until the maintainer finishes review.
|
||||
|
||||
Conflicts are expected when Oxi and RustBox changed the same functionality. Resolve them in the flat RustBox paths, keeping RustBox-specific behavior where required.
|
||||
|
||||
After resolving backend conflicts and porting any relevant UI behavior, finish the sync:
|
||||
|
||||
```bash
|
||||
./scripts/finish-upstream.sh
|
||||
```
|
||||
|
||||
If Oxi changed its frontend or root-level files, the finish step refuses to advance the baseline until you explicitly acknowledge those reviews:
|
||||
|
||||
```bash
|
||||
./scripts/finish-upstream.sh --ui-reviewed --root-reviewed
|
||||
```
|
||||
|
||||
The finish step runs the RustBox release build gate and only then stages the new `UPSTREAM_BASE`. Review `git status` and commit the sync. `--skip-build` exists for exceptional local workflows, but CI should still run the full gate before merge.
|
||||
|
||||
If the sync is rejected, discard the review branch; the main branch and its `UPSTREAM_BASE` remain unchanged.
|
||||
|
||||
## Port one Oxi feature
|
||||
|
||||
For a useful Oxi commit that should be brought in before a full baseline update:
|
||||
|
||||
```bash
|
||||
./scripts/oxi-port-commit.sh <oxi-commit>
|
||||
```
|
||||
|
||||
This creates `port/oxi-<sha>` and maps only the backend portion of that single commit. It intentionally does not change `UPSTREAM_BASE`, because RustBox has not consumed the complete Oxi history up to that revision.
|
||||
|
||||
When a later full sync includes a previously ported feature, Git may report that some hunks are already present; review those conflicts instead of duplicating the code.
|
||||
|
||||
## UI policy
|
||||
|
||||
Oxi frontend source is never copied into RustBox. For a new Oxi feature that includes frontend work:
|
||||
|
||||
- consume or port the backend/API changes;
|
||||
- inspect the corresponding Oxi frontend diff to understand behavior;
|
||||
- implement that behavior in RustBox `src/web/`;
|
||||
- keep the same-origin RustBox API/UI model;
|
||||
- do not add `.ts` or `.tsx` sources.
|
||||
|
||||
## RustBox-owned boundaries
|
||||
|
||||
These areas require explicit review and should not be replaced blindly by upstream changes:
|
||||
|
||||
- `src/web/`;
|
||||
- `src/ui.rs`;
|
||||
- RustBox UI routing and same-origin behavior;
|
||||
- RustBox ManageSieve integration;
|
||||
- `dev.sh`;
|
||||
- native packaging/release scripts;
|
||||
- RustBox branding and documentation.
|
||||
|
||||
The goal is to keep following Oxi protocol/backend improvements without restoring its split frontend/backend repository structure.
|
||||
@@ -0,0 +1,28 @@
|
||||
# RustBox validation
|
||||
|
||||
Release source checks:
|
||||
|
||||
- one root Cargo package (`Cargo.toml` at repository root);
|
||||
- no `backend/` application directory;
|
||||
- no separate `frontend/` directory;
|
||||
- no `.ts` or `.tsx` source files;
|
||||
- no Node/Bun/Next/React build dependency;
|
||||
- UI files live under `src/web/` and are embedded into the Rust executable;
|
||||
- mail endpoints are configuration-owned; the login request contains only email/password/session preference;
|
||||
- authenticated mail routes reuse the resolved session connection profile;
|
||||
- CSS is built by Cargo `build.rs` from `src/web/css/manifest.txt`;
|
||||
- no CSS gradients and no Unicode pseudo-icons in the browser UI;
|
||||
- all UI action icons are served from the embedded SVG sprite;
|
||||
- `dev.sh` runs the root Cargo package;
|
||||
- native release build outputs `target/release/rustbox`;
|
||||
- `UPSTREAM_BASE` records the consumed Oxi backend revision;
|
||||
- upstream tooling maps Oxi `backend/` deltas into RustBox's flat paths and never imports Oxi frontend source.
|
||||
|
||||
Full release gate on a host with Rust/Cargo and dependency access:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test --locked
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
./scripts/build-release.sh
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# Request-path IMAP reuse
|
||||
|
||||
`RealImapClient` owns a pool shared by all request tasks using that client.
|
||||
The identity includes host, port, TLS, email and password, stored as a SHA-256
|
||||
digest of length-prefixed fields. Credential changes cannot reuse old sessions.
|
||||
|
||||
Each identity has at most three pooled connections, including connections being
|
||||
established or exclusively leased. Checkout waits at most ten seconds for a slot.
|
||||
Reused sessions must pass a ten-second NOOP; failed validation closes the socket
|
||||
and checkout opens a replacement. TCP/TLS/LOGIN establishment is bounded by ten
|
||||
seconds. Only a fully consumed, successful operation explicitly returns its
|
||||
session. Errors and cancellation close it, so partial responses cannot leak into
|
||||
another operation. No request operation is automatically retried.
|
||||
|
||||
Idle sessions expire after two minutes, checked on checkout and by a sweep every
|
||||
30 seconds. The sweep removes unused identity entries too. Closing sockets avoids
|
||||
IMAP CLOSE, which would expunge deleted messages. Selected-mailbox operations
|
||||
always SELECT their own folder; STATUS and LIST do not rely on prior selection.
|
||||
|
||||
Background header sync discovers existing UIDs with one UID/flag fetch, filters
|
||||
them to the requested range, and fetches headers in sets of at most 250 UIDs.
|
||||
Large gaps left by deleted messages do not create empty FETCH batches.
|
||||
|
||||
Pooled paths: LIST, SELECT status, extended STATUS, headers, body, UID/flag
|
||||
reconciliation, CONDSTORE changed flags, quota and folder-size reads. The public
|
||||
trait and HTTP API remain unchanged. Dedicated realtime IDLE sessions still use `connect` directly.
|
||||
|
||||
Follow-up: pool mutations after auditing MOVE/UID EXPUNGE fallbacks that catch
|
||||
protocol errors. Mutations retain fresh connections and logout.
|
||||
|
||||
Quota reads return sessions only after a matching tagged OK, including an empty
|
||||
quota result. Unsupported QUOTA (NO/BAD) returns None but discards the lease.
|
||||
Errors, EOF and quota timeouts discard it too. Folder-size reads reuse sessions
|
||||
after successful FETCH completion (or an empty SELECT); a timed-out FETCH retains
|
||||
the existing partial-size result but discards the unfinished session.
|
||||
|
||||
Debug logs include a short credential-identity digest prefix and checkout/return
|
||||
actions (connect, reuse, discard, recycle), without logging credentials.
|
||||
|
||||
Tests use a local plaintext fake IMAP server and the actual async-imap client to
|
||||
verify login reuse, error disposal, failed NOOP recovery, exclusive checkout
|
||||
bounds, abandoned leases, credential isolation and stale-session eviction.
|
||||
|
||||
An ignored TLS regression can be run against a real server with credentials in
|
||||
`TEST_IMAP_HOST`, `TEST_IMAP_EMAIL`, and `TEST_IMAP_PASSWORD`:
|
||||
`cargo test real_tls_reads_reuse_one_login -- --ignored --nocapture`.
|
||||
It asserts one connection for repeated LIST/SELECT/header/quota/size operations
|
||||
on a server that returns tagged OK for quota requests (including no quota).
|
||||
|
||||
The original read-path pool did reuse TLS sessions, but omitted quota and size
|
||||
reads. A server returning OK without quota data sends `/api/quota` into its
|
||||
per-folder size fallback, opening and logging out one fresh connection per
|
||||
folder while ordinary pooled sessions remain open. Regression coverage now
|
||||
includes that fallback rather than only LIST and SELECT operations.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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/`.
|
||||
@@ -0,0 +1,97 @@
|
||||
use mail_parser::MessageParser;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/email_theme.rs"]
|
||||
mod email_theme;
|
||||
|
||||
use email_theme::{detect_email_theme, EmailTheme};
|
||||
|
||||
fn main() {
|
||||
let debugging_dir = Path::new("../debugging");
|
||||
|
||||
let test_cases = [
|
||||
("dark-1.eml", "dark"),
|
||||
("dark-2.eml", "adaptive"),
|
||||
("dark-3.eml", "adaptive"),
|
||||
("dark-4.eml", "adaptive"),
|
||||
("light-1.eml", "light"),
|
||||
("light-2.eml", "light"),
|
||||
("light-3.eml", "light"),
|
||||
("light-4.eml", "adaptive"),
|
||||
("light-5.eml", "light"),
|
||||
("light-6.eml", "light"),
|
||||
("light-7.eml", "adaptive"),
|
||||
("light-8.eml", "light"),
|
||||
("light-9.eml", "light"),
|
||||
("transparent-1.eml", "transparent"),
|
||||
("transparent-2.eml", "transparent"),
|
||||
("transparent-3.eml", "transparent"),
|
||||
("transparent-4.eml", "transparent"),
|
||||
("transparent-5.eml", "transparent"),
|
||||
("text-1.eml", "transparent"),
|
||||
("text-2.eml", "transparent"),
|
||||
("new-light.eml", "light"),
|
||||
("light-10.eml", "light"),
|
||||
("light-11.eml", "light"),
|
||||
("light-12.eml", "adaptive"),
|
||||
("padding.eml", "transparent"),
|
||||
("light-13.eml", "light"),
|
||||
("text-3.eml", "transparent"),
|
||||
];
|
||||
|
||||
println!(
|
||||
"{:<20} | {:<12} | {:<12} | Result",
|
||||
"File", "Expected", "Detected"
|
||||
);
|
||||
println!("{}", "-".repeat(60));
|
||||
|
||||
let mut passed = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
for (filename, expected) in test_cases {
|
||||
let path = debugging_dir.join(filename);
|
||||
let result = test_file(&path, expected);
|
||||
let status = if result.0 { "✅" } else { "❌" };
|
||||
println!(
|
||||
"{:<20} | {:<12} | {:<12} | {}",
|
||||
filename, expected, result.1, status
|
||||
);
|
||||
if result.0 {
|
||||
passed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "-".repeat(60));
|
||||
println!("Passed: {}, Failed: {}", passed, failed);
|
||||
}
|
||||
|
||||
fn test_file(path: &Path, expected: &str) -> (bool, String) {
|
||||
let content = match fs::read(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (false, format!("Error reading: {}", e)),
|
||||
};
|
||||
|
||||
let msg = match MessageParser::default().parse(&content) {
|
||||
Some(m) => m,
|
||||
None => return (false, "Parse failed".to_string()),
|
||||
};
|
||||
|
||||
let html = msg.body_html(0).map(|s| s.to_string());
|
||||
|
||||
let detected = match html {
|
||||
Some(ref h) if !h.is_empty() => match detect_email_theme(h) {
|
||||
Some(EmailTheme::Dark) => "dark",
|
||||
Some(EmailTheme::Light) => "light",
|
||||
Some(EmailTheme::Transparent) => "transparent",
|
||||
Some(EmailTheme::Adaptive) => "adaptive",
|
||||
None => "none",
|
||||
},
|
||||
_ => "none",
|
||||
};
|
||||
|
||||
(detected == expected, detected.to_string())
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_git_command(args, repo_path: Path) -> bytes:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get_files_to_archive(repo_path: Path) -> list[str]:
|
||||
output = run_git_command(
|
||||
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
repo_path,
|
||||
)
|
||||
files = output.decode("utf-8", errors="surrogateescape").split("\0")
|
||||
return [f for f in files if f]
|
||||
|
||||
|
||||
def make_zip(repo_path: Path, output_zip: Path) -> None:
|
||||
files = get_files_to_archive(repo_path)
|
||||
|
||||
output_zip = output_zip.resolve()
|
||||
if output_zip.exists():
|
||||
output_zip.unlink()
|
||||
|
||||
with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for rel_path in files:
|
||||
abs_path = repo_path / rel_path
|
||||
|
||||
if not abs_path.exists():
|
||||
continue
|
||||
|
||||
if abs_path.resolve() == output_zip:
|
||||
continue
|
||||
|
||||
zf.write(abs_path, arcname=rel_path)
|
||||
|
||||
print(f"Created: {output_zip}")
|
||||
print(f"Added files: {len(files)}")
|
||||
|
||||
|
||||
def main():
|
||||
repo_path = Path.cwd()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
output_zip = Path(sys.argv[1])
|
||||
else:
|
||||
output_zip = repo_path / f"{repo_path.name}.zip"
|
||||
|
||||
try:
|
||||
run_git_command(["rev-parse", "--show-toplevel"], repo_path)
|
||||
except subprocess.CalledProcessError:
|
||||
print("Error: this directory is not a Git repository.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
make_zip(repo_path, output_zip)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS user_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_meta (key, value) VALUES ('schema_version', '1');
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Folder cache: stores the IMAP folder hierarchy
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
name TEXT PRIMARY KEY,
|
||||
delimiter TEXT,
|
||||
parent TEXT,
|
||||
flags TEXT NOT NULL DEFAULT '',
|
||||
is_subscribed INTEGER NOT NULL DEFAULT 1,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
highest_modseq INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Message header cache: stores synced email headers per folder
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
uid INTEGER NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
message_id TEXT,
|
||||
in_reply_to TEXT,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
from_address TEXT NOT NULL DEFAULT '',
|
||||
from_name TEXT NOT NULL DEFAULT '',
|
||||
to_addresses TEXT NOT NULL DEFAULT '',
|
||||
cc_addresses TEXT NOT NULL DEFAULT '',
|
||||
date TEXT NOT NULL,
|
||||
flags TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
has_attachments INTEGER NOT NULL DEFAULT 0,
|
||||
snippet TEXT NOT NULL DEFAULT '',
|
||||
body_cached INTEGER NOT NULL DEFAULT 0,
|
||||
body_html TEXT,
|
||||
body_text TEXT,
|
||||
references_header TEXT,
|
||||
PRIMARY KEY (uid, folder),
|
||||
FOREIGN KEY (folder) REFERENCES folders(name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_folder_date ON messages(folder, date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_in_reply_to ON messages(in_reply_to);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add a sortable integer timestamp column for proper date ordering.
|
||||
ALTER TABLE messages ADD COLUMN date_epoch INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Rebuild index to use the new column for sorting.
|
||||
DROP INDEX IF EXISTS idx_messages_folder_date;
|
||||
CREATE INDEX idx_messages_folder_date ON messages(folder, date_epoch DESC);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Separate timestamp for when a folder's messages were last synced from IMAP,
|
||||
-- distinct from `updated_at` which tracks when the folder list was last synced.
|
||||
ALTER TABLE folders ADD COLUMN messages_updated_at TEXT;
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE IF NOT EXISTS drafts (
|
||||
id TEXT PRIMARY KEY,
|
||||
to_addresses TEXT NOT NULL DEFAULT '',
|
||||
cc_addresses TEXT NOT NULL DEFAULT '',
|
||||
bcc_addresses TEXT NOT NULL DEFAULT '',
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
text_body TEXT NOT NULL DEFAULT '',
|
||||
html_body TEXT,
|
||||
in_reply_to TEXT,
|
||||
references_header TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS draft_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
draft_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (draft_id) REFERENCES drafts(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Cache attachment metadata and raw headers alongside the message body.
|
||||
ALTER TABLE messages ADD COLUMN attachments_json TEXT;
|
||||
ALTER TABLE messages ADD COLUMN raw_headers TEXT;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
company TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
last_contacted TEXT,
|
||||
contact_count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_last_contacted ON contacts(last_contacted DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_contact_count ON contacts(contact_count DESC);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL,
|
||||
signature_html TEXT NOT NULL DEFAULT '',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sound INTEGER NOT NULL DEFAULT 0,
|
||||
folders TEXT NOT NULL DEFAULT '["INBOX"]',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO notification_preferences (id) VALUES (1);
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS contact_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_group_members (
|
||||
group_id TEXT NOT NULL,
|
||||
contact_id TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, contact_id),
|
||||
FOREIGN KEY (group_id) REFERENCES contact_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cgm_contact_id ON contact_group_members(contact_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE messages ADD COLUMN reaction TEXT;
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_tags (
|
||||
tag_id TEXT NOT NULL,
|
||||
message_uid INTEGER NOT NULL,
|
||||
message_folder TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (tag_id, message_uid, message_folder),
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (message_uid, message_folder) REFERENCES messages(uid, folder) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_tags_message ON message_tags(message_uid, message_folder);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_tags_tag ON message_tags(tag_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS display_preferences (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
density TEXT NOT NULL DEFAULT 'comfortable',
|
||||
theme TEXT NOT NULL DEFAULT 'system',
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE display_preferences ADD COLUMN compose_format TEXT NOT NULL DEFAULT 'html';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE display_preferences ADD COLUMN deep_index BOOLEAN NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE calendar_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
attendees TEXT NOT NULL DEFAULT '[]',
|
||||
organizer TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'confirmed',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_uid TEXT,
|
||||
source_message_uid INTEGER,
|
||||
source_message_folder TEXT,
|
||||
meeting_url TEXT,
|
||||
color TEXT,
|
||||
reminder_minutes INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_calendar_events_time ON calendar_events(start_time, end_time);
|
||||
CREATE INDEX idx_calendar_events_source_uid ON calendar_events(source_uid);
|
||||
|
||||
CREATE TABLE calendar_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
default_view TEXT NOT NULL DEFAULT 'month',
|
||||
week_starts_on INTEGER NOT NULL DEFAULT 0,
|
||||
working_hours_start TEXT NOT NULL DEFAULT '09:00',
|
||||
working_hours_end TEXT NOT NULL DEFAULT '17:00',
|
||||
time_format TEXT NOT NULL DEFAULT '12h',
|
||||
zoom_link TEXT NOT NULL DEFAULT '',
|
||||
teams_link TEXT NOT NULL DEFAULT '',
|
||||
meet_link TEXT NOT NULL DEFAULT '',
|
||||
caldav_url TEXT NOT NULL DEFAULT '',
|
||||
caldav_username TEXT NOT NULL DEFAULT '',
|
||||
caldav_password TEXT NOT NULL DEFAULT '',
|
||||
caldav_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE meeting_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url_template TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add email_theme column to messages table
|
||||
-- NULL = not yet detected (only relevant when body_html exists)
|
||||
-- 0 = Light, 1 = Dark, 2 = Transparent
|
||||
ALTER TABLE messages ADD COLUMN email_theme INTEGER;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE display_preferences
|
||||
ADD COLUMN animation_mode TEXT NULL;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Denormalized table of known email addresses extracted from message headers.
|
||||
-- Avoids expensive json_each() scanning of to_addresses/cc_addresses columns.
|
||||
CREATE TABLE IF NOT EXISTS known_addresses (
|
||||
email TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (email)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_known_addresses_name ON known_addresses(name);
|
||||
|
||||
-- Backfill from existing messages: from_address
|
||||
INSERT INTO known_addresses (email, name)
|
||||
SELECT from_address, COALESCE(from_name, '')
|
||||
FROM messages
|
||||
WHERE from_address != ''
|
||||
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != '';
|
||||
|
||||
-- Backfill from existing messages: to_addresses (JSON array)
|
||||
INSERT INTO known_addresses (email, name)
|
||||
SELECT value->>'address', COALESCE(value->>'name', '')
|
||||
FROM messages, json_each(to_addresses)
|
||||
WHERE json_valid(to_addresses)
|
||||
AND value->>'address' IS NOT NULL
|
||||
AND value->>'address' != ''
|
||||
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != '';
|
||||
|
||||
-- Backfill from existing messages: cc_addresses (JSON array)
|
||||
INSERT INTO known_addresses (email, name)
|
||||
SELECT value->>'address', COALESCE(value->>'name', '')
|
||||
FROM messages, json_each(cc_addresses)
|
||||
WHERE json_valid(cc_addresses)
|
||||
AND value->>'address' IS NOT NULL
|
||||
AND value->>'address' != ''
|
||||
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != '';
|
||||
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE display_preferences ADD COLUMN preview_pane TEXT NOT NULL DEFAULT 'right';
|
||||
ALTER TABLE display_preferences ADD COLUMN ui_scale TEXT NOT NULL DEFAULT 'normal';
|
||||
ALTER TABLE display_preferences ADD COLUMN color_preset TEXT NOT NULL DEFAULT 'sage';
|
||||
ALTER TABLE display_preferences ADD COLUMN accent_color TEXT NOT NULL DEFAULT '#6f9c86';
|
||||
ALTER TABLE display_preferences ADD COLUMN font_family TEXT NOT NULL DEFAULT 'system';
|
||||
ALTER TABLE display_preferences ADD COLUMN font_size INTEGER NOT NULL DEFAULT 13;
|
||||
ALTER TABLE display_preferences ADD COLUMN row_height INTEGER NOT NULL DEFAULT 58;
|
||||
ALTER TABLE display_preferences ADD COLUMN folder_pane_width INTEGER NOT NULL DEFAULT 228;
|
||||
ALTER TABLE display_preferences ADD COLUMN message_pane_width INTEGER NOT NULL DEFAULT 420;
|
||||
ALTER TABLE display_preferences ADD COLUMN border_radius INTEGER NOT NULL DEFAULT 4;
|
||||
ALTER TABLE display_preferences ADD COLUMN contrast TEXT NOT NULL DEFAULT 'normal';
|
||||
ALTER TABLE display_preferences ADD COLUMN show_avatars INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE display_preferences ADD COLUMN show_attachment_icons INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE display_preferences ADD COLUMN show_flags INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE display_preferences ADD COLUMN reduce_motion INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE display_preferences ADD COLUMN date_format TEXT NOT NULL DEFAULT 'locale';
|
||||
ALTER TABLE display_preferences ADD COLUMN time_format TEXT NOT NULL DEFAULT 'locale';
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS special_folders (
|
||||
role TEXT PRIMARY KEY CHECK(role IN ('sent','drafts','trash','junk','archive')),
|
||||
folder_name TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE identities ADD COLUMN reply_to TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE identities ADD COLUMN bcc TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE identities ADD COLUMN organization TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE identities ADD COLUMN signature_text TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE identities ADD COLUMN signature_new INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE identities ADD COLUMN signature_reply INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE identities ADD COLUMN signature_forward INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE identities ADD COLUMN signature_position TEXT NOT NULL DEFAULT 'above';
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS saved_searches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
query_string TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_searches_name ON saved_searches(name COLLATE NOCASE);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE notification_preferences ADD COLUMN dnd_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE notification_preferences ADD COLUMN dnd_start TEXT NOT NULL DEFAULT '22:00';
|
||||
ALTER TABLE notification_preferences ADD COLUMN dnd_end TEXT NOT NULL DEFAULT '07:00';
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE display_preferences ADD COLUMN background_color TEXT NOT NULL DEFAULT '#121513';
|
||||
ALTER TABLE display_preferences ADD COLUMN panel_color TEXT NOT NULL DEFAULT '#181c19';
|
||||
ALTER TABLE display_preferences ADD COLUMN text_color TEXT NOT NULL DEFAULT '#edf0ec';
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS mail_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
messages_per_page INTEGER NOT NULL DEFAULT 50,
|
||||
mark_read_delay_ms INTEGER NOT NULL DEFAULT 0,
|
||||
remote_images TEXT NOT NULL DEFAULT 'ask',
|
||||
preferred_body TEXT NOT NULL DEFAULT 'auto',
|
||||
thread_mode TEXT NOT NULL DEFAULT 'threaded',
|
||||
collapse_quotes INTEGER NOT NULL DEFAULT 1,
|
||||
show_snippets INTEGER NOT NULL DEFAULT 1,
|
||||
default_sort TEXT NOT NULL DEFAULT 'date_desc',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO mail_preferences (id) VALUES (1);
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE contacts ADD COLUMN phone TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE contacts ADD COLUMN job_title TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE contacts ADD COLUMN address TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE contacts ADD COLUMN website TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE contacts ADD COLUMN birthday TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_photos (
|
||||
contact_id TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE contacts ADD COLUMN emails_json TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE contacts ADD COLUMN phones_json TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE contacts ADD COLUMN addresses_json TEXT NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE drafts ADD COLUMN request_mdn INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE drafts ADD COLUMN priority TEXT NOT NULL DEFAULT 'normal';
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE mail_preferences ADD COLUMN flagged_on_top INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folder_preferences (
|
||||
folder_name TEXT PRIMARY KEY REFERENCES folders(name) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
flagged_on_top INTEGER CHECK(flagged_on_top IN (0, 1) OR flagged_on_top IS NULL),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE display_preferences ADD COLUMN compose_autosave_ms INTEGER NOT NULL DEFAULT 3000;
|
||||
ALTER TABLE display_preferences ADD COLUMN default_priority TEXT NOT NULL DEFAULT 'normal';
|
||||
ALTER TABLE display_preferences ADD COLUMN request_mdn_default INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE display_preferences ADD COLUMN attachment_reminder INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE display_preferences ADD COLUMN icon_size INTEGER NOT NULL DEFAULT 18;
|
||||
ALTER TABLE display_preferences ADD COLUMN toolbar_density TEXT NOT NULL DEFAULT 'normal';
|
||||
ALTER TABLE display_preferences ADD COLUMN sidebar_item_height INTEGER NOT NULL DEFAULT 29;
|
||||
ALTER TABLE display_preferences ADD COLUMN reader_max_width INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE display_preferences ADD COLUMN composer_width INTEGER NOT NULL DEFAULT 900;
|
||||
ALTER TABLE display_preferences ADD COLUMN selection_color TEXT NOT NULL DEFAULT '#28352e';
|
||||
ALTER TABLE display_preferences ADD COLUMN unread_font_weight INTEGER NOT NULL DEFAULT 730;
|
||||
ALTER TABLE display_preferences ADD COLUMN unread_badge_style TEXT NOT NULL DEFAULT 'dot';
|
||||
ALTER TABLE display_preferences ADD COLUMN compact_headers INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE display_preferences ADD COLUMN show_borders INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE display_preferences ADD COLUMN scrollbar_density TEXT NOT NULL DEFAULT 'thin';
|
||||
ALTER TABLE display_preferences ADD COLUMN monochrome INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE display_preferences ADD COLUMN folder_background TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE display_preferences ADD COLUMN message_background TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE display_preferences ADD COLUMN reader_background TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE drafts ADD COLUMN compose_format TEXT NOT NULL DEFAULT 'html';
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE mail_preferences ADD COLUMN sync_imap_keywords INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE tags ADD COLUMN imap_keyword TEXT NOT NULL DEFAULT '';
|
||||
UPDATE tags SET imap_keyword = 'RustBox-' || replace(id, '-', '') WHERE imap_keyword = '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_imap_keyword ON tags(imap_keyword) WHERE imap_keyword <> '';
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE display_preferences ADD COLUMN compose_font_family TEXT NOT NULL DEFAULT 'system';
|
||||
ALTER TABLE display_preferences ADD COLUMN reply_position TEXT NOT NULL DEFAULT 'above';
|
||||
ALTER TABLE display_preferences ADD COLUMN quote_style TEXT NOT NULL DEFAULT 'standard';
|
||||
ALTER TABLE display_preferences ADD COLUMN forward_mode TEXT NOT NULL DEFAULT 'inline';
|
||||
ALTER TABLE display_preferences ADD COLUMN spellcheck_enabled INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE drafts ADD COLUMN remote_folder TEXT;
|
||||
ALTER TABLE drafts ADD COLUMN remote_uid INTEGER;
|
||||
ALTER TABLE drafts ADD COLUMN remote_uid_validity INTEGER;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- OpenPGP keyring metadata. Secret key material is stored outside SQLite.
|
||||
CREATE TABLE IF NOT EXISTS pgp_keys (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
key_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
public_key_armor TEXT NOT NULL,
|
||||
secret_key_path TEXT,
|
||||
has_secret INTEGER NOT NULL DEFAULT 0,
|
||||
trust TEXT NOT NULL DEFAULT 'unknown',
|
||||
source TEXT NOT NULL DEFAULT 'import',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pgp_keys_email ON pgp_keys(lower(email));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pgp_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
wkd_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
hkp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
require_trusted_encryption INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT OR IGNORE INTO pgp_preferences (id) VALUES (1);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE identities ADD COLUMN pgp_fingerprint TEXT;
|
||||
ALTER TABLE identities ADD COLUMN pgp_sign_default INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE identities ADD COLUMN pgp_encrypt_default INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE identities ADD COLUMN pgp_inline_default INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE drafts ADD COLUMN pgp_mode TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE drafts ADD COLUMN pgp_inline INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE drafts ADD COLUMN pgp_attach_public_key INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE drafts ADD COLUMN pgp_attach_recipient_keys INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=RustBox webmail
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rustbox
|
||||
Group=rustbox
|
||||
Environment=RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml
|
||||
ExecStart=/usr/local/bin/rustbox
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
StateDirectory=rustbox
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "error: Rust/Cargo is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if find "$ROOT" -type f \( -name '*.ts' -o -name '*.tsx' \) -print -quit | grep -q .; then
|
||||
echo "error: TypeScript source found in RustBox" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cargo test --locked
|
||||
cargo build --release --locked
|
||||
|
||||
BIN="$ROOT/target/release/rustbox"
|
||||
test -x "$BIN"
|
||||
"$BIN" --version
|
||||
printf 'Built RustBox: %s\n' "$BIN"
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
UI_REVIEWED=0
|
||||
ROOT_REVIEWED=0
|
||||
SKIP_BUILD=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--ui-reviewed) UI_REVIEWED=1 ;;
|
||||
--root-reviewed) ROOT_REVIEWED=1 ;;
|
||||
--skip-build) SKIP_BUILD=1 ;;
|
||||
*) echo "usage: $0 [--ui-reviewed] [--root-reviewed] [--skip-build]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "error: not a Git checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
PENDING_FILE="$(git rev-parse --git-path rustbox-upstream-pending)"
|
||||
if [ ! -f "$PENDING_FILE" ]; then
|
||||
echo "error: no pending Oxi sync; run ./scripts/sync-upstream.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE="$(sed -n 's/^base=//p' "$PENDING_FILE")"
|
||||
TARGET_SHA="$(sed -n 's/^target=//p' "$PENDING_FILE")"
|
||||
BRANCH="$(sed -n 's/^branch=//p' "$PENDING_FILE")"
|
||||
CURRENT_BRANCH="$(git branch --show-current)"
|
||||
|
||||
if [ -z "$BASE" ] || [ -z "$TARGET_SHA" ] || [ -z "$BRANCH" ]; then
|
||||
echo "error: invalid pending sync metadata" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CURRENT_BRANCH" != "$BRANCH" ]; then
|
||||
echo "error: pending Oxi sync belongs to branch '$BRANCH'; current branch is '$CURRENT_BRANCH'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if git diff --name-only --diff-filter=U | grep -q .; then
|
||||
echo "error: unresolved merge conflicts remain" >&2
|
||||
git diff --name-only --diff-filter=U >&2
|
||||
exit 1
|
||||
fi
|
||||
if find . -path ./.git -prune -o -type f \( -name '*.ts' -o -name '*.tsx' \) -print -quit | grep -q .; then
|
||||
echo "error: TypeScript source exists in RustBox" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff --quiet "$BASE..$TARGET_SHA" -- frontend/; then
|
||||
if [ "$UI_REVIEWED" -ne 1 ]; then
|
||||
echo "error: Oxi frontend changed between $BASE and $TARGET_SHA." >&2
|
||||
echo "Review/port the relevant behavior, then rerun with --ui-reviewed." >&2
|
||||
git diff --name-status "$BASE..$TARGET_SHA" -- frontend/ >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! git diff --quiet "$BASE..$TARGET_SHA" -- . ':(exclude)backend/**' ':(exclude)frontend/**'; then
|
||||
if [ "$ROOT_REVIEWED" -ne 1 ]; then
|
||||
echo "error: Oxi root-level files changed between $BASE and $TARGET_SHA." >&2
|
||||
echo "Review relevant build/config/docs changes, then rerun with --root-reviewed." >&2
|
||||
git diff --name-status "$BASE..$TARGET_SHA" -- . ':(exclude)backend/**' ':(exclude)frontend/**' >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$SKIP_BUILD" -ne 1 ]; then
|
||||
./scripts/build-release.sh
|
||||
else
|
||||
echo "warning: build gate skipped by explicit --skip-build" >&2
|
||||
fi
|
||||
|
||||
printf '%s\n' "$TARGET_SHA" > UPSTREAM_BASE
|
||||
git add UPSTREAM_BASE
|
||||
rm -f "$PENDING_FILE"
|
||||
|
||||
printf 'Oxi sync review completed: %s -> %s\n' "$BASE" "$TARGET_SHA"
|
||||
printf 'UPSTREAM_BASE is staged. Review git status and commit the sync.\n'
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN="$ROOT/target/release/rustbox"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "error: run as root (sudo ./scripts/install-systemd.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$BIN" ]; then
|
||||
echo "error: $BIN not found; run ./scripts/build-release.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
getent group rustbox >/dev/null || groupadd --system rustbox
|
||||
id rustbox >/dev/null 2>&1 || useradd --system --gid rustbox --home-dir /var/lib/rustbox --shell /usr/sbin/nologin rustbox
|
||||
install -d -o root -g rustbox -m 0750 /etc/rustbox
|
||||
install -m 0755 "$BIN" /usr/local/bin/rustbox
|
||||
install -m 0644 "$ROOT/packaging/systemd/rustbox.service" /etc/systemd/system/rustbox.service
|
||||
if [ ! -f /etc/rustbox/rustbox.toml ]; then
|
||||
install -o root -g rustbox -m 0640 "$ROOT/config.example.toml" /etc/rustbox/rustbox.toml
|
||||
fi
|
||||
RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml /usr/local/bin/rustbox --check-config
|
||||
systemctl daemon-reload
|
||||
echo "Installed. Review /etc/rustbox/rustbox.toml, then: systemctl enable --now rustbox"
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
REMOTE="upstream-oxi"
|
||||
COMMIT="${1:-}"
|
||||
|
||||
if [ -z "$COMMIT" ]; then
|
||||
echo "usage: $0 <oxi-commit>" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "error: not a Git checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "error: working tree must be clean before porting an Oxi commit" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||
echo "error: Oxi remote is not configured; run ./scripts/setup-upstream.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch "$REMOTE" --tags >/dev/null
|
||||
SHA="$(git rev-parse "$COMMIT^{commit}")"
|
||||
PARENT="$(git rev-parse "$SHA^1")"
|
||||
branch="port/oxi-${SHA:0:12}"
|
||||
git switch -c "$branch"
|
||||
|
||||
tmp_raw="$(mktemp)"
|
||||
tmp_mapped="$(mktemp)"
|
||||
cleanup() { rm -f "$tmp_raw" "$tmp_mapped"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
git diff --binary --find-renames "$PARENT..$SHA" -- backend/ > "$tmp_raw"
|
||||
sed \
|
||||
-e 's#^diff --git a/backend/#diff --git a/#' \
|
||||
-e 's# b/backend/# b/#' \
|
||||
-e 's#^--- a/backend/#--- a/#' \
|
||||
-e 's#^+++ b/backend/#+++ b/#' \
|
||||
-e 's#^rename from backend/#rename from #' \
|
||||
-e 's#^rename to backend/#rename to #' \
|
||||
-e 's#^copy from backend/#copy from #' \
|
||||
-e 's#^copy to backend/#copy to #' \
|
||||
-e 's#^Binary files a/backend/#Binary files a/#' \
|
||||
-e 's# and b/backend/# and b/#' \
|
||||
"$tmp_raw" > "$tmp_mapped"
|
||||
|
||||
if [ ! -s "$tmp_mapped" ]; then
|
||||
echo "Oxi commit $SHA has no backend/ changes to map."
|
||||
else
|
||||
git apply --3way --index "$tmp_mapped"
|
||||
fi
|
||||
|
||||
printf 'Ported backend portion of Oxi commit %s onto branch %s.\n' "$SHA" "$branch"
|
||||
printf 'UPSTREAM_BASE was intentionally NOT changed.\n'
|
||||
printf 'Review the original commit, especially frontend changes: git show %s -- frontend/\n' "$SHA"
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
REMOTE="upstream-oxi"
|
||||
TARGET="${1:-upstream-oxi/main}"
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "error: not a Git checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||
echo "error: Oxi remote is not configured; run ./scripts/setup-upstream.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch "$REMOTE" --tags >/dev/null
|
||||
BASE="$(tr -d '[:space:]' < UPSTREAM_BASE)"
|
||||
TARGET_SHA="$(git rev-parse "$TARGET^{commit}")"
|
||||
|
||||
if ! git merge-base --is-ancestor "$BASE" "$TARGET_SHA"; then
|
||||
echo "error: recorded Oxi base is not an ancestor of $TARGET ($TARGET_SHA)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'RustBox Oxi status\n'
|
||||
printf ' consumed: %s\n' "$BASE"
|
||||
printf ' target: %s\n' "$TARGET_SHA"
|
||||
printf ' commits: %s\n' "$(git rev-list --count "$BASE..$TARGET_SHA")"
|
||||
|
||||
printf '\nOxi backend changes that can be mapped into RustBox root:\n'
|
||||
git diff --name-status "$BASE..$TARGET_SHA" -- backend/ || true
|
||||
|
||||
printf '\nOxi frontend changes requiring manual RustBox UI port:\n'
|
||||
git diff --name-status "$BASE..$TARGET_SHA" -- frontend/ || true
|
||||
|
||||
printf '\nOther Oxi repository changes to review manually:\n'
|
||||
git diff --name-status "$BASE..$TARGET_SHA" -- . ':(exclude)backend/**' ':(exclude)frontend/**' || true
|
||||
|
||||
printf '\nUpstream commits:\n'
|
||||
git log --oneline --decorate --no-merges "$BASE..$TARGET_SHA" || true
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
REMOTE="upstream-oxi"
|
||||
URL="https://github.com/c0h1b4/oxi.git"
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "error: RustBox must be a Git checkout before configuring Oxi upstream." >&2
|
||||
echo "Initialize it first:" >&2
|
||||
echo " git init" >&2
|
||||
echo " git add ." >&2
|
||||
echo " git commit -m 'RustBox 0.0.1 baseline'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||
current="$(git remote get-url "$REMOTE")"
|
||||
if [ "$current" != "$URL" ]; then
|
||||
echo "error: remote '$REMOTE' already points to: $current" >&2
|
||||
echo "expected: $URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
git remote add "$REMOTE" "$URL"
|
||||
fi
|
||||
|
||||
git fetch "$REMOTE" --tags
|
||||
|
||||
BASE="$(tr -d '[:space:]' < UPSTREAM_BASE)"
|
||||
if ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
|
||||
echo "error: recorded Oxi base commit is not available after fetch: $BASE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Oxi upstream configured.\n'
|
||||
printf 'Remote: %s -> %s\n' "$REMOTE" "$URL"
|
||||
printf 'Consumed Oxi baseline: %s\n' "$BASE"
|
||||
printf 'Latest upstream-oxi/main: %s\n' "$(git rev-parse upstream-oxi/main)"
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
REMOTE="upstream-oxi"
|
||||
TARGET="${1:-upstream-oxi/main}"
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "error: run this inside a RustBox Git checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||
echo "error: create the initial RustBox commit before syncing Oxi" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "error: working tree must be clean before Oxi sync" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||
echo "error: Oxi remote is not configured; run ./scripts/setup-upstream.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch "$REMOTE" --tags
|
||||
BASE="$(tr -d '[:space:]' < UPSTREAM_BASE)"
|
||||
TARGET_SHA="$(git rev-parse "$TARGET^{commit}")"
|
||||
|
||||
if [ "$BASE" = "$TARGET_SHA" ]; then
|
||||
echo "RustBox already consumes Oxi $TARGET_SHA"
|
||||
exit 0
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$BASE" "$TARGET_SHA"; then
|
||||
echo "error: $TARGET_SHA does not descend from recorded Oxi base $BASE" >&2
|
||||
echo "Refuse automatic mapping; review the upstream history manually." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
branch="sync/oxi-$(date -u +%Y%m%d-%H%M%S)"
|
||||
git switch -c "$branch"
|
||||
|
||||
tmp_raw="$(mktemp)"
|
||||
tmp_mapped="$(mktemp)"
|
||||
cleanup() { rm -f "$tmp_raw" "$tmp_mapped"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# RustBox flattened Oxi's backend/ crate into the repository root. Generate
|
||||
# only the upstream backend delta, then rewrite patch metadata from
|
||||
# backend/<path> to <path>. File contents are not rewritten.
|
||||
git diff --binary --find-renames "$BASE..$TARGET_SHA" -- backend/ > "$tmp_raw"
|
||||
sed \
|
||||
-e 's#^diff --git a/backend/#diff --git a/#' \
|
||||
-e 's# b/backend/# b/#' \
|
||||
-e 's#^--- a/backend/#--- a/#' \
|
||||
-e 's#^+++ b/backend/#+++ b/#' \
|
||||
-e 's#^rename from backend/#rename from #' \
|
||||
-e 's#^rename to backend/#rename to #' \
|
||||
-e 's#^copy from backend/#copy from #' \
|
||||
-e 's#^copy to backend/#copy to #' \
|
||||
-e 's#^Binary files a/backend/#Binary files a/#' \
|
||||
-e 's# and b/backend/# and b/#' \
|
||||
"$tmp_raw" > "$tmp_mapped"
|
||||
|
||||
PENDING_FILE="$(git rev-parse --git-path rustbox-upstream-pending)"
|
||||
printf 'base=%s\ntarget=%s\nbranch=%s\n' "$BASE" "$TARGET_SHA" "$branch" > "$PENDING_FILE"
|
||||
|
||||
if [ -s "$tmp_mapped" ]; then
|
||||
if ! git apply --3way --index "$tmp_mapped"; then
|
||||
echo >&2
|
||||
echo "Oxi backend changes produced conflicts on branch $branch." >&2
|
||||
echo "Resolve them in the flat RustBox paths, then run ./scripts/finish-upstream.sh." >&2
|
||||
echo "UPSTREAM_BASE has NOT been advanced." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if find . -path ./.git -prune -o -type f \( -name '*.ts' -o -name '*.tsx' \) -print -quit | grep -q .; then
|
||||
echo "error: TypeScript appeared in the RustBox source tree; remove/port it before continuing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '\nMapped Oxi backend %s -> %s into RustBox on branch %s.\n' "$BASE" "$TARGET_SHA" "$branch"
|
||||
printf 'UPSTREAM_BASE remains at %s until the review is explicitly finished.\n' "$BASE"
|
||||
printf '\nFrontend changes were NOT imported. Review them with:\n'
|
||||
printf ' git diff --name-status %s..%s -- frontend/\n' "$BASE" "$TARGET_SHA"
|
||||
printf ' git diff %s..%s -- frontend/\n' "$BASE" "$TARGET_SHA"
|
||||
printf '\nOther Oxi root changes were NOT imported. Review them with:\n'
|
||||
printf " git diff %s..%s -- . ':(exclude)backend/**' ':(exclude)frontend/**'\n" "$BASE" "$TARGET_SHA"
|
||||
printf '\nAfter resolving/porting and testing, run ./scripts/finish-upstream.sh.\n'
|
||||
@@ -0,0 +1,43 @@
|
||||
# BACKEND KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Rust Axum application core for RustBox, forked from Oxi. It manages IMAP/SMTP connections, SQLite caching, and Tantivy search.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
src/
|
||||
├── auth/ # Session store, IMAP auth, CSRF, middleware
|
||||
├── db/ # SQLite pool and entity-specific queries
|
||||
├── imap/ # IMAP protocol client (Real + Mock)
|
||||
├── realtime/ # WebSocket, EventBus, and IMAP IDLE
|
||||
├── routes/ # API endpoints and router assembly
|
||||
├── search/ # Tantivy full-text indexing
|
||||
├── smtp/ # Lettre-based mail submission
|
||||
├── sieve/ # ManageSieve protocol client
|
||||
├── config.rs # Figment TOML + env configuration
|
||||
├── error.rs # AppError enum to JSON envelope
|
||||
└── main.rs # Entry point
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| Router Setup | `routes/mod.rs` | 1600+ lines. Assembles all sub-routers. |
|
||||
| IMAP Protocol | `imap/client.rs` | 1900+ lines. Core protocol implementation. |
|
||||
| Message API | `routes/messages.rs` | Handles list, get, and move operations. |
|
||||
| DB Persistence | `db/messages.rs` | SQL queries for message metadata. |
|
||||
| Search Logic | `search/engine.rs` | Indexing and querying Tantivy. |
|
||||
| Mail Sending | `smtp/client.rs` | SMTP submission via lettre. |
|
||||
|
||||
## CONVENTIONS
|
||||
- Return `Result<T, AppError>` for all fallible logic.
|
||||
- Use `auth_guard` middleware for any route requiring a session.
|
||||
- Keep database logic in `db/`. Routes shouldn't run raw SQL.
|
||||
- Access IMAP through the `ImapClient` trait. This enables testing with mocks.
|
||||
- Config values map from environment variables to `AppConfig` via Figment.
|
||||
|
||||
## COMPLEXITY HOTSPOTS
|
||||
- `imap/client.rs`: Handles complex IMAP state and parsing. It's the largest file.
|
||||
- `routes/mod.rs`: Massive router tree. Hard to navigate due to size.
|
||||
- `realtime/idle.rs`: Manages background IMAP IDLE tasks and connection pooling.
|
||||
- `db/messages.rs`: Complex SQL for threading and folder synchronization.
|
||||
@@ -0,0 +1,189 @@
|
||||
use axum::extract::Request;
|
||||
use axum::http::{Method, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
/// Methods that mutate state and therefore require CSRF protection.
|
||||
const STATE_CHANGING_METHODS: [Method; 4] = [
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::DELETE,
|
||||
Method::PATCH,
|
||||
];
|
||||
|
||||
/// Axum middleware that enforces CSRF protection on state-changing requests.
|
||||
///
|
||||
/// For POST, PUT, DELETE, and PATCH requests the `X-Requested-With` header
|
||||
/// must be present (any non-empty value is accepted). GET, HEAD, OPTIONS, and
|
||||
/// all other safe methods pass through without the check.
|
||||
///
|
||||
/// This works in concert with `SameSite=Strict` session cookies: the cookie
|
||||
/// attribute prevents the browser from sending credentials on cross-origin
|
||||
/// requests, while this header check blocks cross-origin form submissions
|
||||
/// (browsers do not allow custom headers in simple/form requests).
|
||||
pub async fn csrf_protection(request: Request, next: Next) -> Response {
|
||||
if STATE_CHANGING_METHODS.contains(request.method())
|
||||
&& !request.headers().contains_key("x-requested-with")
|
||||
{
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"code": "CSRF_REJECTED",
|
||||
"message": "Missing X-Requested-With header",
|
||||
"status": 403
|
||||
}
|
||||
});
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
[("content-type", "application/json")],
|
||||
body.to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Build a minimal router with CSRF middleware applied.
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.route("/test", get(|| async { "ok" }).post(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(csrf_protection))
|
||||
}
|
||||
|
||||
/// Helper: send a request and return the status code and parsed JSON body.
|
||||
async fn send(
|
||||
method: Method,
|
||||
with_header: bool,
|
||||
) -> (StatusCode, serde_json::Value) {
|
||||
let mut builder = Request::builder().method(method).uri("/test");
|
||||
|
||||
if with_header {
|
||||
builder = builder.header("x-requested-with", "XMLHttpRequest");
|
||||
}
|
||||
|
||||
let request = builder.body(Body::empty()).unwrap();
|
||||
let response = app().oneshot(request).await.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = if body.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&body).unwrap_or(serde_json::Value::String(
|
||||
String::from_utf8_lossy(&body).to_string(),
|
||||
))
|
||||
};
|
||||
(status, json)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_passes_without_header() {
|
||||
let (status, _) = send(Method::GET, false).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_without_header_returns_403() {
|
||||
let (status, json) = send(Method::POST, false).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(json["error"]["code"], "CSRF_REJECTED");
|
||||
assert_eq!(json["error"]["message"], "Missing X-Requested-With header");
|
||||
assert_eq!(json["error"]["status"], 403);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_with_header_passes_through() {
|
||||
let (status, _) = send(Method::POST, true).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_without_header_returns_403() {
|
||||
let app = Router::new()
|
||||
.route("/test", axum::routing::put(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(csrf_protection));
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri("/test")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_without_header_returns_403() {
|
||||
let app = Router::new()
|
||||
.route("/test", axum::routing::delete(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(csrf_protection));
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri("/test")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_without_header_returns_403() {
|
||||
let app = Router::new()
|
||||
.route("/test", axum::routing::patch(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(csrf_protection));
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::PATCH)
|
||||
.uri("/test")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn head_passes_without_header() {
|
||||
let app = Router::new()
|
||||
.route("/test", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(csrf_protection));
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::HEAD)
|
||||
.uri("/test")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn options_passes_without_header() {
|
||||
let app = Router::new()
|
||||
.route("/test", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(csrf_protection));
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::OPTIONS)
|
||||
.uri("/test")
|
||||
.body(Body::empty())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use async_imap::error::Error as ImapError;
|
||||
|
||||
/// Result of validating credentials against an IMAP server.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum AuthResult {
|
||||
/// The credentials are valid; login succeeded (and we immediately logged out).
|
||||
Success,
|
||||
/// The server rejected the credentials (wrong email or password).
|
||||
InvalidCredentials,
|
||||
/// Could not reach or communicate with the IMAP server.
|
||||
ServerUnreachable(String),
|
||||
}
|
||||
|
||||
/// Validate an email/password pair against a real IMAP server.
|
||||
///
|
||||
/// Connects to the given `host:port`, optionally using TLS, attempts a LOGIN
|
||||
/// command with the provided credentials, and immediately logs out on success.
|
||||
///
|
||||
/// This function does **not** maintain a persistent IMAP connection; it is
|
||||
/// intended solely for credential validation at login time.
|
||||
pub async fn validate_imap_credentials(
|
||||
host: &str,
|
||||
port: u16,
|
||||
tls: bool,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> AuthResult {
|
||||
if tls {
|
||||
validate_tls(host, port, email, password).await
|
||||
} else {
|
||||
validate_plain(host, port, email, password).await
|
||||
}
|
||||
}
|
||||
|
||||
/// TLS path: connect via `tokio::net::TcpStream`, upgrade with
|
||||
/// `async_native_tls::TlsConnector`, then wrap in `async_imap::Client`.
|
||||
async fn validate_tls(host: &str, port: u16, email: &str, password: &str) -> AuthResult {
|
||||
let addr = format!("{host}:{port}");
|
||||
|
||||
let tcp_stream = match tokio::net::TcpStream::connect(&addr).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return AuthResult::ServerUnreachable(e.to_string()),
|
||||
};
|
||||
|
||||
let tls_connector = async_native_tls::TlsConnector::new();
|
||||
let tls_stream = match tls_connector.connect(host, tcp_stream).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return AuthResult::ServerUnreachable(e.to_string()),
|
||||
};
|
||||
|
||||
let mut client = async_imap::Client::new(tls_stream);
|
||||
|
||||
// Read the server greeting before attempting login.
|
||||
match client.read_response().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()),
|
||||
None => return AuthResult::ServerUnreachable("connection closed before greeting".into()),
|
||||
}
|
||||
|
||||
attempt_login(client, email, password).await
|
||||
}
|
||||
|
||||
/// Non-TLS path: connect via `tokio::net::TcpStream`, then wrap directly in
|
||||
/// `async_imap::Client`.
|
||||
async fn validate_plain(host: &str, port: u16, email: &str, password: &str) -> AuthResult {
|
||||
let addr = format!("{host}:{port}");
|
||||
|
||||
let tcp_stream = match tokio::net::TcpStream::connect(&addr).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return AuthResult::ServerUnreachable(e.to_string()),
|
||||
};
|
||||
|
||||
let mut client = async_imap::Client::new(tcp_stream);
|
||||
|
||||
// Read the server greeting before attempting login.
|
||||
match client.read_response().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => return AuthResult::ServerUnreachable(e.to_string()),
|
||||
None => return AuthResult::ServerUnreachable("connection closed before greeting".into()),
|
||||
}
|
||||
|
||||
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
|
||||
where
|
||||
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + std::fmt::Debug + Send,
|
||||
{
|
||||
match client.login(email, password).await {
|
||||
Ok(mut session) => {
|
||||
// Successfully authenticated; clean up.
|
||||
let _ = session.logout().await;
|
||||
AuthResult::Success
|
||||
}
|
||||
Err((err, _client)) => classify_login_error(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify an IMAP error into `InvalidCredentials` or `ServerUnreachable`.
|
||||
///
|
||||
/// A `No` response from the server means the credentials were rejected.
|
||||
/// Everything else (I/O errors, BAD responses, parse errors, etc.) is
|
||||
/// treated as a server/network problem.
|
||||
fn classify_login_error(err: ImapError) -> AuthResult {
|
||||
match err {
|
||||
ImapError::No(_) => AuthResult::InvalidCredentials,
|
||||
other => AuthResult::ServerUnreachable(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Connecting to an unreachable server should return `ServerUnreachable`.
|
||||
///
|
||||
/// We use 127.0.0.1:19999, a port that (with very high probability) has
|
||||
/// nothing listening on localhost.
|
||||
#[tokio::test]
|
||||
async fn unreachable_server_returns_server_unreachable() {
|
||||
let result =
|
||||
validate_imap_credentials("127.0.0.1", 19999, false, "user@test.com", "pass").await;
|
||||
|
||||
assert!(
|
||||
matches!(result, AuthResult::ServerUnreachable(_)),
|
||||
"expected ServerUnreachable, got {result:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Same test but with TLS enabled: should also return `ServerUnreachable`.
|
||||
#[tokio::test]
|
||||
async fn unreachable_server_tls_returns_server_unreachable() {
|
||||
let result =
|
||||
validate_imap_credentials("127.0.0.1", 19999, true, "user@test.com", "pass").await;
|
||||
|
||||
assert!(
|
||||
matches!(result, AuthResult::ServerUnreachable(_)),
|
||||
"expected ServerUnreachable, got {result:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify the error classifier maps `No` to `InvalidCredentials`.
|
||||
#[test]
|
||||
fn classify_no_as_invalid_credentials() {
|
||||
let err = ImapError::No("Authentication failed".into());
|
||||
assert_eq!(classify_login_error(err), AuthResult::InvalidCredentials);
|
||||
}
|
||||
|
||||
/// Verify the error classifier maps I/O errors to `ServerUnreachable`.
|
||||
#[test]
|
||||
fn classify_io_as_server_unreachable() {
|
||||
let err = ImapError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionRefused,
|
||||
"refused",
|
||||
));
|
||||
let result = classify_login_error(err);
|
||||
assert!(
|
||||
matches!(result, AuthResult::ServerUnreachable(_)),
|
||||
"expected ServerUnreachable, got {result:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify the error classifier maps `Bad` responses to `ServerUnreachable`.
|
||||
#[test]
|
||||
fn classify_bad_as_server_unreachable() {
|
||||
let err = ImapError::Bad("server error".into());
|
||||
let result = classify_login_error(err);
|
||||
assert!(
|
||||
matches!(result, AuthResult::ServerUnreachable(_)),
|
||||
"expected ServerUnreachable, got {result:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Request;
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use super::session::SessionStore;
|
||||
|
||||
pub const BROWSER_COOKIE: &str = "rustbox_browser";
|
||||
pub const ACTIVE_ACCOUNT_HEADER: &str = "X-Active-Account";
|
||||
|
||||
const UNAUTHORIZED_BODY: &str = r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid or expired session","status":401}}"#;
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[("content-type", "application/json")],
|
||||
UNAUTHORIZED_BODY,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn account_expired(account_id: &str) -> Response {
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"code": "ACCOUNT_SESSION_EXPIRED",
|
||||
"message": "Account session has expired",
|
||||
"status": 401,
|
||||
"accountId": account_id,
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[("content-type", "application/json")],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn extract_cookie_value(req: &Request, cookie_name: &str) -> Option<String> {
|
||||
for value in req.headers().get_all("cookie") {
|
||||
let Ok(header_str) = value.to_str() else {
|
||||
continue;
|
||||
};
|
||||
for segment in header_str.split(';') {
|
||||
let trimmed = segment.trim();
|
||||
let prefix = format!("{}=", cookie_name);
|
||||
if let Some(cookie_value) = trimmed.strip_prefix(&prefix) {
|
||||
let cookie_value = cookie_value.trim();
|
||||
if !cookie_value.is_empty() {
|
||||
return Some(cookie_value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_session_cookie(req: &Request, account_id: &str) -> Option<String> {
|
||||
let cookie_name = format!("rustbox_session_{}", account_id);
|
||||
extract_cookie_value(req, &cookie_name)
|
||||
}
|
||||
|
||||
fn extract_active_account_header(req: &Request) -> Option<String> {
|
||||
req.headers()
|
||||
.get(ACTIVE_ACCOUNT_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn extract_active_account_query(req: &Request) -> Option<String> {
|
||||
let uri = req.uri();
|
||||
let query = uri.query()?;
|
||||
for pair in query.split('&') {
|
||||
if let Some(value) = pair.strip_prefix("account_id=") {
|
||||
let decoded = urlencoding_decode(value);
|
||||
if !decoded.is_empty() {
|
||||
return Some(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn urlencoding_decode(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
let hex: String = chars.by_ref().take(2).collect();
|
||||
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
||||
result.push(byte as char);
|
||||
} else {
|
||||
result.push('%');
|
||||
result.push_str(&hex);
|
||||
}
|
||||
} else if c == '+' {
|
||||
result.push(' ');
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn auth_guard(mut req: Request, next: Next) -> Response {
|
||||
let store = match req.extensions().get::<Arc<SessionStore>>() {
|
||||
Some(s) => Arc::clone(s),
|
||||
None => return unauthorized(),
|
||||
};
|
||||
|
||||
let browser_id = match extract_cookie_value(&req, BROWSER_COOKIE) {
|
||||
Some(b) => b,
|
||||
None => return unauthorized(),
|
||||
};
|
||||
|
||||
let account_id = match extract_active_account_header(&req).or_else(|| extract_active_account_query(&req)) {
|
||||
Some(a) => a,
|
||||
None => return unauthorized(),
|
||||
};
|
||||
|
||||
let token = match extract_session_cookie(&req, &account_id) {
|
||||
Some(t) => t,
|
||||
None => return account_expired(&account_id),
|
||||
};
|
||||
|
||||
let session = match store.get_account_session(&browser_id, &account_id, &token) {
|
||||
Some(s) => s,
|
||||
None => return account_expired(&account_id),
|
||||
};
|
||||
|
||||
req.extensions_mut().insert(session);
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::*;
|
||||
use crate::auth::session::{AccountSession, SessionStore};
|
||||
|
||||
fn guarded_router(store: Arc<SessionStore>) -> Router {
|
||||
let handler = |Extension(session): Extension<AccountSession>| async move {
|
||||
serde_json::json!({ "email": session.email }).to_string().into_response()
|
||||
};
|
||||
|
||||
Router::new()
|
||||
.route("/protected", get(handler))
|
||||
.layer(middleware::from_fn(auth_guard))
|
||||
.layer(Extension(store))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
router: Router,
|
||||
req: Request<Body>,
|
||||
) -> (StatusCode, serde_json::Value) {
|
||||
let resp = router.oneshot(req).await.expect("request should succeed");
|
||||
let status = resp.status();
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).expect("body should be valid JSON");
|
||||
(status, json)
|
||||
}
|
||||
|
||||
struct TestSession {
|
||||
browser_id: String,
|
||||
account_id: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
fn create_test_session(store: &SessionStore, email: &str, password: &str, user_hash: &str) -> TestSession {
|
||||
let browser_id = store.create_browser();
|
||||
let (token, account_id) = store.add_account_to_browser(
|
||||
&browser_id,
|
||||
email.to_string(),
|
||||
password.to_string(),
|
||||
user_hash.to_string(),
|
||||
"imap.example.com".to_string(),
|
||||
993,
|
||||
true,
|
||||
"smtp.example.com".to_string(),
|
||||
587,
|
||||
true,
|
||||
);
|
||||
TestSession { browser_id, account_id, token }
|
||||
}
|
||||
|
||||
fn build_auth_headers(session: &TestSession) -> String {
|
||||
format!(
|
||||
"rustbox_browser={}; rustbox_session_{}={}",
|
||||
session.browser_id, session.account_id, session.token
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_expired_matches_frontend_contract() {
|
||||
let response = account_expired("expired-account");
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(response.headers()["content-type"], "application/json");
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let actual: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let expected: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../tests/fixtures/account-session-expired.json"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_cookies_returns_401() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let router = guarded_router(store);
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(json["error"]["code"], "UNAUTHORIZED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_browser_cookie_returns_401() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
|
||||
.header("cookie", format!("rustbox_session_{}={}", session.account_id, session.token))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(json["error"]["code"], "UNAUTHORIZED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_active_account_header_returns_401() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header("cookie", format!("rustbox_browser={}", session.browser_id))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(json["error"]["code"], "UNAUTHORIZED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_session_cookie_returns_account_expired() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
|
||||
.header("cookie", format!("rustbox_browser={}", session.browser_id))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
|
||||
assert_eq!(json["error"]["accountId"], session.account_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_token_returns_account_expired() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
|
||||
.header("cookie", format!(
|
||||
"rustbox_browser={}; rustbox_session_{}=invalid-token",
|
||||
session.browser_id, session.account_id
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
|
||||
assert_eq!(json["error"]["accountId"], session.account_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_session_returns_200_with_email() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
|
||||
.header("cookie", build_auth_headers(&session))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(json["email"], "alice@example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_browser_returns_account_expired() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
|
||||
let wrong_browser_id = store.create_browser();
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
|
||||
.header("cookie", format!(
|
||||
"rustbox_browser={}; rustbox_session_{}={}",
|
||||
wrong_browser_id, session.account_id, session.token
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
|
||||
assert_eq!(json["error"]["accountId"], session.account_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_session_returns_account_expired() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_millis(50)));
|
||||
let session = create_test_session(&store.as_ref(), "bob@example.com", "pass", "hash");
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
let router = guarded_router(store);
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
|
||||
.header("cookie", build_auth_headers(&session))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
|
||||
assert_eq!(json["error"]["accountId"], session.account_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cookies_among_multiple_cookies() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session = create_test_session(&store, "multi@example.com", "pass", "hash");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
|
||||
.header(
|
||||
"cookie",
|
||||
format!(
|
||||
"theme=dark; rustbox_browser={}; lang=en; rustbox_session_{}={}",
|
||||
session.browser_id, session.account_id, session.token
|
||||
),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(json["email"], "multi@example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_cookie_name_returns_401() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session = create_test_session(&store, "wrong@example.com", "pass", "hash");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
|
||||
.header("cookie", format!(
|
||||
"other_browser={}; rustbox_session_{}={}",
|
||||
session.browser_id, session.account_id, session.token
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, _) = send(router, req).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_account_in_header_returns_account_expired() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session1 = create_test_session(&store, "user1@example.com", "pass", "hash1");
|
||||
let session2 = create_test_session(&store, "user2@example.com", "pass", "hash2");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session2.account_id)
|
||||
.header("cookie", build_auth_headers(&session1))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
|
||||
assert_eq!(json["error"]["accountId"], session2.account_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_cookie_value_finds_correct_cookie() {
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header("cookie", "a=1; b=2; c=3")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(extract_cookie_value(&req, "a"), Some("1".to_string()));
|
||||
assert_eq!(extract_cookie_value(&req, "b"), Some("2".to_string()));
|
||||
assert_eq!(extract_cookie_value(&req, "c"), Some("3".to_string()));
|
||||
assert_eq!(extract_cookie_value(&req, "d"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_active_account_header_works() {
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.header(ACTIVE_ACCOUNT_HEADER, "account-123")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(extract_active_account_header(&req), Some("account-123".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_active_account_header_missing() {
|
||||
let req = Request::builder()
|
||||
.uri("/protected")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(extract_active_account_header(&req), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_active_account_query_works() {
|
||||
let req = Request::builder()
|
||||
.uri("/protected?account_id=account-123")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(extract_active_account_query(&req), Some("account-123".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_active_account_query_with_encoding() {
|
||||
let req = Request::builder()
|
||||
.uri("/protected?account_id=abc%40example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(extract_active_account_query(&req), Some("abc@example.com".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_active_account_query_missing() {
|
||||
let req = Request::builder()
|
||||
.uri("/protected?other=value")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(extract_active_account_query(&req), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_id_from_query_param_works() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri(format!("/protected?account_id={}", session.account_id))
|
||||
.header("cookie", build_auth_headers(&session))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(json["email"], "alice@example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_takes_precedence_over_query_param() {
|
||||
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
|
||||
let session1 = create_test_session(&store, "user1@example.com", "pass", "hash1");
|
||||
let session2 = create_test_session(&store, "user2@example.com", "pass", "hash2");
|
||||
let router = guarded_router(Arc::clone(&store));
|
||||
|
||||
let req = Request::builder()
|
||||
.uri(format!("/protected?account_id={}", session2.account_id))
|
||||
.header(ACTIVE_ACCOUNT_HEADER, &session1.account_id)
|
||||
.header("cookie", format!(
|
||||
"rustbox_browser={}; rustbox_session_{}={}",
|
||||
session1.browser_id, session1.account_id, session1.token
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let (status, json) = send(router, req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(json["email"], "user1@example.com");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod csrf;
|
||||
pub mod imap_auth;
|
||||
pub mod middleware;
|
||||
pub mod session;
|
||||
pub mod user_data;
|
||||
+1331
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,139 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
mod embedded {
|
||||
use refinery::embed_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 {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(email.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
// Format each byte as two lowercase hex digits.
|
||||
result.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Provision a per-user data directory under `data_dir`.
|
||||
///
|
||||
/// Creates `{data_dir}/{user_hash}/` with a `tantivy/` subdirectory,
|
||||
/// opens (or creates) `db.sqlite`, and runs refinery migrations on it.
|
||||
///
|
||||
/// This function is idempotent: calling it multiple times with the same
|
||||
/// arguments is safe and will not produce errors.
|
||||
///
|
||||
/// Returns the path to the user directory on success.
|
||||
pub fn provision_user_data(data_dir: &str, user_hash: &str) -> Result<PathBuf, String> {
|
||||
let user_dir = PathBuf::from(data_dir).join(user_hash);
|
||||
|
||||
// Create the user directory and the tantivy subdirectory.
|
||||
let tantivy_dir = user_dir.join("tantivy");
|
||||
fs::create_dir_all(&tantivy_dir).map_err(|e| format!("failed to create directories: {e}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(mut perms) = fs::metadata(&user_dir).map(|m| m.permissions()) {
|
||||
perms.set_mode(0o700); // rwx------
|
||||
let _ = fs::set_permissions(&user_dir, perms);
|
||||
}
|
||||
}
|
||||
|
||||
// Open (or create) the SQLite database.
|
||||
let db_path = user_dir.join("db.sqlite");
|
||||
let mut conn = rusqlite::Connection::open(&db_path)
|
||||
.map_err(|e| format!("failed to open database: {e}"))?;
|
||||
|
||||
// Run refinery migrations.
|
||||
embedded::migrations::runner()
|
||||
.run(&mut conn)
|
||||
.map_err(|e| format!("failed to run migrations: {e}"))?;
|
||||
|
||||
Ok(user_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_email_is_deterministic() {
|
||||
let email = "alice@example.com";
|
||||
let h1 = hash_email(email);
|
||||
let h2 = hash_email(email);
|
||||
assert_eq!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_email_differs_for_different_emails() {
|
||||
let h1 = hash_email("alice@example.com");
|
||||
let h2 = hash_email("bob@example.com");
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_email_produces_64_char_hex_string() {
|
||||
let h = hash_email("test@example.com");
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(
|
||||
h.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"hash should be hex-encoded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_creates_directories_and_db() {
|
||||
let tmp = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let data_dir = tmp.path().to_str().unwrap();
|
||||
let user_hash = hash_email("test@example.com");
|
||||
|
||||
let user_dir = provision_user_data(data_dir, &user_hash).expect("provisioning failed");
|
||||
|
||||
assert!(user_dir.exists(), "user directory should exist");
|
||||
assert!(user_dir.join("db.sqlite").exists(), "db.sqlite should exist");
|
||||
assert!(
|
||||
user_dir.join("tantivy").is_dir(),
|
||||
"tantivy/ subdirectory should exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_is_idempotent() {
|
||||
let tmp = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let data_dir = tmp.path().to_str().unwrap();
|
||||
let user_hash = hash_email("idem@example.com");
|
||||
|
||||
let dir1 = provision_user_data(data_dir, &user_hash).expect("first call failed");
|
||||
let dir2 = provision_user_data(data_dir, &user_hash).expect("second call failed");
|
||||
|
||||
assert_eq!(dir1, dir2);
|
||||
assert!(dir2.join("db.sqlite").exists());
|
||||
assert!(dir2.join("tantivy").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_creates_user_meta_table() {
|
||||
let tmp = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let data_dir = tmp.path().to_str().unwrap();
|
||||
let user_hash = hash_email("meta@example.com");
|
||||
|
||||
let user_dir = provision_user_data(data_dir, &user_hash).expect("provisioning failed");
|
||||
|
||||
let conn =
|
||||
rusqlite::Connection::open(user_dir.join("db.sqlite")).expect("failed to open db");
|
||||
|
||||
let value: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM user_meta WHERE key = 'schema_version'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("failed to query user_meta");
|
||||
|
||||
assert_eq!(value, "1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use ical::IcalParser;
|
||||
use ical::parser::ical::component::IcalCalendar;
|
||||
use std::io::BufReader;
|
||||
|
||||
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> {
|
||||
let reader = BufReader::new(ics_text.as_bytes());
|
||||
let parser = IcalParser::new(reader);
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
for calendar_result in parser {
|
||||
let calendar: IcalCalendar =
|
||||
calendar_result.map_err(|e| format!("Failed to parse ICS: {e}"))?;
|
||||
|
||||
for vevent in calendar.events {
|
||||
let mut title = String::new();
|
||||
let mut description = String::new();
|
||||
let mut location = String::new();
|
||||
let mut start_time = String::new();
|
||||
let mut end_time = String::new();
|
||||
let mut all_day = false;
|
||||
let mut uid = None;
|
||||
let mut rrule = None;
|
||||
let mut organizer = String::new();
|
||||
let mut attendees: Vec<String> = Vec::new();
|
||||
let mut url = None;
|
||||
|
||||
for prop in &vevent.properties {
|
||||
let value = prop.value.as_deref().unwrap_or("");
|
||||
|
||||
match prop.name.as_str() {
|
||||
"SUMMARY" => {
|
||||
title = value.to_string();
|
||||
}
|
||||
"DESCRIPTION" => {
|
||||
description = value.to_string();
|
||||
}
|
||||
"LOCATION" => {
|
||||
location = value.to_string();
|
||||
}
|
||||
"DTSTART" => {
|
||||
let parsed = parse_ical_datetime(value, &prop.params);
|
||||
all_day = is_date_only(value);
|
||||
start_time = parsed;
|
||||
}
|
||||
"DTEND" => {
|
||||
end_time = parse_ical_datetime(value, &prop.params);
|
||||
}
|
||||
"UID" => {
|
||||
uid = Some(value.to_string());
|
||||
}
|
||||
"RRULE" => {
|
||||
rrule = Some(value.to_string());
|
||||
}
|
||||
"ORGANIZER" => {
|
||||
organizer = extract_email_from_cal_address(value);
|
||||
}
|
||||
"ATTENDEE" => {
|
||||
attendees.push(extract_email_from_cal_address(value));
|
||||
}
|
||||
"URL" if !value.is_empty() => {
|
||||
url = Some(value.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if title.is_empty() {
|
||||
title = "(No title)".to_string();
|
||||
}
|
||||
|
||||
// If end_time is empty, default to start_time + 1 hour (or same day for all-day)
|
||||
if end_time.is_empty() {
|
||||
end_time = start_time.clone();
|
||||
}
|
||||
|
||||
if start_time.is_empty() {
|
||||
// Skip events without a start time
|
||||
continue;
|
||||
}
|
||||
|
||||
let attendees_json =
|
||||
serde_json::to_string(&attendees).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
events.push(CreateEvent {
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
start_time,
|
||||
end_time,
|
||||
all_day,
|
||||
recurrence_rule: rrule,
|
||||
attendees: attendees_json,
|
||||
organizer,
|
||||
status: "confirmed".to_string(),
|
||||
source: "ics".to_string(),
|
||||
source_uid: uid,
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: url,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Check if a DTSTART/DTEND value is date-only (no time component).
|
||||
fn is_date_only(value: &str) -> bool {
|
||||
// Date-only values are 8 characters: YYYYMMDD
|
||||
// Date-time values are at least 15: YYYYMMDDTHHmmSS
|
||||
value.len() == 8 && value.chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Parse an iCalendar date/datetime value to ISO 8601 format.
|
||||
fn parse_ical_datetime(value: &str, params: &Option<Vec<(String, Vec<String>)>>) -> String {
|
||||
// Check for TZID parameter
|
||||
let _tzid = params.as_ref().and_then(|ps| {
|
||||
ps.iter()
|
||||
.find(|(k, _)| k == "TZID")
|
||||
.and_then(|(_, v)| v.first())
|
||||
.cloned()
|
||||
});
|
||||
|
||||
let value = value.trim();
|
||||
|
||||
if value.len() == 8 && value.chars().all(|c| c.is_ascii_digit()) {
|
||||
// Date only: YYYYMMDD -> YYYY-MM-DD
|
||||
return format!("{}-{}-{}", &value[0..4], &value[4..6], &value[6..8]);
|
||||
}
|
||||
|
||||
if value.len() >= 15 && value.contains('T') {
|
||||
// DateTime: YYYYMMDDTHHmmSS or YYYYMMDDTHHmmSSZ
|
||||
let date_part = &value[0..8];
|
||||
let time_part = &value[9..];
|
||||
|
||||
let year = &date_part[0..4];
|
||||
let month = &date_part[4..6];
|
||||
let day = &date_part[6..8];
|
||||
|
||||
let hour = &time_part[0..2];
|
||||
let min = &time_part[2..4];
|
||||
let sec = if time_part.len() >= 6 {
|
||||
&time_part[4..6]
|
||||
} else {
|
||||
"00"
|
||||
};
|
||||
|
||||
let is_utc = value.ends_with('Z');
|
||||
let suffix = if is_utc { "Z" } else { "" };
|
||||
|
||||
return format!("{year}-{month}-{day}T{hour}:{min}:{sec}{suffix}");
|
||||
}
|
||||
|
||||
// Fallback: return as-is
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
/// Extract email address from a cal-address value (e.g., "mailto:user@example.com").
|
||||
fn extract_email_from_cal_address(value: &str) -> String {
|
||||
if let Some(stripped) = value.strip_prefix("mailto:") {
|
||||
stripped.to_string()
|
||||
} else if let Some(stripped) = value.strip_prefix("MAILTO:") {
|
||||
stripped.to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_ICS: &str = r#"BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20250306T090000Z
|
||||
DTEND:20250306T100000Z
|
||||
SUMMARY:Team Meeting
|
||||
DESCRIPTION:Weekly team sync
|
||||
LOCATION:Conference Room A
|
||||
UID:event-123@example.com
|
||||
ORGANIZER:mailto:boss@example.com
|
||||
ATTENDEE:mailto:alice@example.com
|
||||
ATTENDEE:mailto:bob@example.com
|
||||
URL:https://zoom.us/j/12345
|
||||
END:VEVENT
|
||||
END:VCALENDAR"#;
|
||||
|
||||
#[test]
|
||||
fn test_parse_basic_event() {
|
||||
let events = parse_ics(SAMPLE_ICS).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let event = &events[0];
|
||||
assert_eq!(event.title, "Team Meeting");
|
||||
assert_eq!(event.description, "Weekly team sync");
|
||||
assert_eq!(event.location, "Conference Room A");
|
||||
assert_eq!(event.start_time, "2025-03-06T09:00:00Z");
|
||||
assert_eq!(event.end_time, "2025-03-06T10:00:00Z");
|
||||
assert!(!event.all_day);
|
||||
assert_eq!(event.organizer, "boss@example.com");
|
||||
assert_eq!(event.source_uid.as_deref(), Some("event-123@example.com"));
|
||||
assert_eq!(
|
||||
event.meeting_url.as_deref(),
|
||||
Some("https://zoom.us/j/12345")
|
||||
);
|
||||
assert_eq!(event.source, "ics");
|
||||
|
||||
let attendees: Vec<String> = serde_json::from_str(&event.attendees).unwrap();
|
||||
assert_eq!(attendees.len(), 2);
|
||||
assert!(attendees.contains(&"alice@example.com".to_string()));
|
||||
assert!(attendees.contains(&"bob@example.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_all_day_event() {
|
||||
let ics = r#"BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20250306
|
||||
DTEND;VALUE=DATE:20250307
|
||||
SUMMARY:All Day Event
|
||||
UID:allday-1@example.com
|
||||
END:VEVENT
|
||||
END:VCALENDAR"#;
|
||||
|
||||
let events = parse_ics(ics).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(events[0].all_day);
|
||||
assert_eq!(events[0].start_time, "2025-03-06");
|
||||
assert_eq!(events[0].end_time, "2025-03-07");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_events() {
|
||||
let ics = r#"BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20250306T090000Z
|
||||
DTEND:20250306T100000Z
|
||||
SUMMARY:Event 1
|
||||
UID:e1@example.com
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20250306T140000Z
|
||||
DTEND:20250306T150000Z
|
||||
SUMMARY:Event 2
|
||||
UID:e2@example.com
|
||||
END:VEVENT
|
||||
END:VCALENDAR"#;
|
||||
|
||||
let events = parse_ics(ics).unwrap();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[0].title, "Event 1");
|
||||
assert_eq!(events[1].title, "Event 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ical_datetime_utc() {
|
||||
assert_eq!(
|
||||
parse_ical_datetime("20250306T090000Z", &None),
|
||||
"2025-03-06T09:00:00Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ical_datetime_local() {
|
||||
assert_eq!(
|
||||
parse_ical_datetime("20250306T090000", &None),
|
||||
"2025-03-06T09:00:00"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ical_date_only() {
|
||||
assert_eq!(parse_ical_datetime("20250306", &None), "2025-03-06");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_email() {
|
||||
assert_eq!(
|
||||
extract_email_from_cal_address("mailto:user@example.com"),
|
||||
"user@example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_email_from_cal_address("MAILTO:user@example.com"),
|
||||
"user@example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_email_from_cal_address("user@example.com"),
|
||||
"user@example.com"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod ics;
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use figment::{
|
||||
Figment,
|
||||
providers::{Env, Format, Toml},
|
||||
};
|
||||
use ipnet::IpNet;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Application configuration loaded via figment.
|
||||
///
|
||||
/// Layers (lowest to highest priority):
|
||||
/// 1. Serde defaults
|
||||
/// 2. Optional TOML (`RUSTBOX_CONFIG`, default `/etc/rustbox/rustbox.toml`)
|
||||
/// 3. Environment variables (flat, e.g. `PORT`, `IMAP_HOST`)
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AppConfig {
|
||||
/// Bind address for the HTTP server.
|
||||
#[serde(default = "default_host")]
|
||||
pub host: String,
|
||||
|
||||
/// Port for the HTTP server.
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
|
||||
/// IMAP server hostname. Required at startup.
|
||||
#[serde(default)]
|
||||
pub imap_host: Option<String>,
|
||||
|
||||
/// IMAP server port.
|
||||
#[serde(default = "default_imap_port")]
|
||||
pub imap_port: u16,
|
||||
|
||||
/// SMTP server hostname. When unset or blank, IMAP host is used.
|
||||
#[serde(default)]
|
||||
pub smtp_host: Option<String>,
|
||||
|
||||
/// SMTP server port.
|
||||
#[serde(default = "default_smtp_port")]
|
||||
pub smtp_port: u16,
|
||||
|
||||
/// Whether TLS is enabled for mail connections.
|
||||
#[serde(default = "default_tls_enabled")]
|
||||
pub tls_enabled: bool,
|
||||
|
||||
/// Directory for persistent data storage.
|
||||
#[serde(default = "default_data_dir")]
|
||||
pub data_dir: String,
|
||||
|
||||
/// Session timeout in hours.
|
||||
#[serde(default = "default_session_timeout_hours")]
|
||||
pub session_timeout_hours: u64,
|
||||
|
||||
/// Whether users may request a persistent "remember me" session.
|
||||
#[serde(default = "default_remember_me_enabled")]
|
||||
pub remember_me_enabled: bool,
|
||||
|
||||
/// Lifetime of a remembered session in days.
|
||||
#[serde(default = "default_remember_me_days")]
|
||||
pub remember_me_days: u64,
|
||||
|
||||
/// Environment the application is running in (development, production)
|
||||
#[serde(default = "default_environment")]
|
||||
pub environment: String,
|
||||
|
||||
/// Optional base path prefix (e.g. "/mail") for serving behind a reverse proxy subpath.
|
||||
#[serde(default)]
|
||||
pub base_path: Option<String>,
|
||||
|
||||
/// ManageSieve host. When unset, the authenticated account's IMAP host is used.
|
||||
#[serde(default)]
|
||||
pub sieve_host: Option<String>,
|
||||
|
||||
/// ManageSieve TCP port.
|
||||
#[serde(default = "default_sieve_port")]
|
||||
pub sieve_port: u16,
|
||||
|
||||
/// ManageSieve transport: `starttls`, `implicit` or `none`.
|
||||
#[serde(default = "default_sieve_tls_mode")]
|
||||
pub sieve_tls_mode: String,
|
||||
|
||||
/// Explicit opt-in for sending reusable credentials over a cleartext Sieve connection.
|
||||
#[serde(default)]
|
||||
pub sieve_allow_insecure_auth: bool,
|
||||
|
||||
/// Network timeout for ManageSieve connect/read/write operations.
|
||||
#[serde(default = "default_sieve_timeout_secs")]
|
||||
pub sieve_timeout_secs: u64,
|
||||
|
||||
/// Comma-separated list of trusted proxy IPs or CIDR ranges that are allowed to set X-Forwarded-For.
|
||||
#[serde(default)]
|
||||
pub trusted_proxies: Option<String>,
|
||||
|
||||
/// Administrator-defined extra headers added to every outgoing message.
|
||||
/// Each entry uses `Header-Name: value`. Structural/MIME headers are rejected.
|
||||
#[serde(default)]
|
||||
pub outgoing_headers: Vec<String>,
|
||||
|
||||
/// Enable Web Key Directory lookups for OpenPGP recipient keys.
|
||||
#[serde(default = "default_pgp_wkd_enabled")]
|
||||
pub pgp_wkd_enabled: bool,
|
||||
|
||||
/// Allow optional HKP-over-HTTPS fallback for OpenPGP discovery.
|
||||
#[serde(default)]
|
||||
pub pgp_hkp_enabled: bool,
|
||||
|
||||
/// HKP-compatible HTTPS endpoint used when the fallback is enabled.
|
||||
#[serde(default = "default_pgp_hkp_url")]
|
||||
pub pgp_hkp_url: String,
|
||||
|
||||
/// Timeout for WKD/HKP network operations.
|
||||
#[serde(default = "default_pgp_network_timeout_secs")]
|
||||
pub pgp_network_timeout_secs: u64,
|
||||
|
||||
/// Maximum accepted OpenPGP key response size.
|
||||
#[serde(default = "default_pgp_max_key_bytes")]
|
||||
pub pgp_max_key_bytes: usize,
|
||||
|
||||
/// Allow authenticated users to export private OpenPGP key material.
|
||||
/// Disabled by default; public-key export is always available.
|
||||
#[serde(default)]
|
||||
pub pgp_allow_private_export: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TrustedProxy {
|
||||
Ip(IpAddr),
|
||||
Cidr(IpNet),
|
||||
}
|
||||
|
||||
impl TrustedProxy {
|
||||
pub fn contains(&self, ip: &IpAddr) -> bool {
|
||||
match self {
|
||||
Self::Ip(trusted_ip) => trusted_ip == ip,
|
||||
Self::Cidr(network) => network.contains(ip),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_host() -> String {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
3001
|
||||
}
|
||||
|
||||
fn default_imap_port() -> u16 {
|
||||
993
|
||||
}
|
||||
|
||||
fn default_smtp_port() -> u16 {
|
||||
587
|
||||
}
|
||||
|
||||
fn default_tls_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_data_dir() -> String {
|
||||
"/data".to_string()
|
||||
}
|
||||
|
||||
fn default_session_timeout_hours() -> u64 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_remember_me_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_remember_me_days() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_environment() -> String {
|
||||
"development".to_string()
|
||||
}
|
||||
|
||||
fn default_sieve_port() -> u16 {
|
||||
4190
|
||||
}
|
||||
|
||||
fn default_sieve_tls_mode() -> String {
|
||||
"starttls".to_string()
|
||||
}
|
||||
|
||||
fn default_sieve_timeout_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_pgp_wkd_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_pgp_hkp_url() -> String {
|
||||
"https://keys.openpgp.org".to_string()
|
||||
}
|
||||
fn default_pgp_network_timeout_secs() -> u64 {
|
||||
10
|
||||
}
|
||||
fn default_pgp_max_key_bytes() -> usize {
|
||||
1024 * 1024
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Use the root path when no reverse-proxy prefix is configured.
|
||||
pub fn normalized_base_path(&self) -> &str {
|
||||
self.base_path
|
||||
.as_deref()
|
||||
.filter(|path| !path.is_empty())
|
||||
.unwrap_or("/")
|
||||
}
|
||||
|
||||
/// Parse the `trusted_proxies` field into IP or CIDR entries.
|
||||
/// Logs warnings for unparseable entries.
|
||||
pub fn parsed_trusted_proxies(&self) -> Vec<TrustedProxy> {
|
||||
let Some(ref proxies) = self.trusted_proxies else {
|
||||
return vec![];
|
||||
};
|
||||
proxies
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(ip) = trimmed.parse::<IpAddr>() {
|
||||
return Some(TrustedProxy::Ip(ip));
|
||||
}
|
||||
match trimmed.parse::<IpNet>() {
|
||||
Ok(network) => Some(TrustedProxy::Cidr(network)),
|
||||
Err(e) => {
|
||||
tracing::warn!(entry = trimmed, error = %e, "ignoring unparseable trusted proxy");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parsed_outgoing_headers(&self) -> Result<Vec<(String, String)>, String> {
|
||||
const RESERVED: &[&str] = &[
|
||||
"from",
|
||||
"sender",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"reply-to",
|
||||
"subject",
|
||||
"date",
|
||||
"message-id",
|
||||
"in-reply-to",
|
||||
"references",
|
||||
"mime-version",
|
||||
"content-type",
|
||||
"content-transfer-encoding",
|
||||
"content-disposition",
|
||||
];
|
||||
let mut parsed = Vec::new();
|
||||
for entry in &self.outgoing_headers {
|
||||
let (raw_name, raw_value) = entry.split_once(':').ok_or_else(|| {
|
||||
format!("Invalid outgoing header '{entry}': expected 'Name: value'")
|
||||
})?;
|
||||
let name = raw_name.trim();
|
||||
let value = raw_value.trim();
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|byte| (33..=126).contains(&byte) && byte != b':')
|
||||
{
|
||||
return Err(format!("Invalid outgoing header name: {name}"));
|
||||
}
|
||||
if RESERVED
|
||||
.iter()
|
||||
.any(|reserved| name.eq_ignore_ascii_case(reserved))
|
||||
{
|
||||
return Err(format!(
|
||||
"Outgoing header '{name}' is managed by RustBox and cannot be overridden"
|
||||
));
|
||||
}
|
||||
if value.contains('\r') || value.contains('\n') {
|
||||
return Err(format!("Outgoing header '{name}' contains a line break"));
|
||||
}
|
||||
if value.is_empty() {
|
||||
return Err(format!("Outgoing header '{name}' has an empty value"));
|
||||
}
|
||||
parsed.push((name.to_string(), value.to_string()));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Load configuration by layering serde defaults, optional TOML, and environment variables.
|
||||
///
|
||||
/// Environment variables are read without a prefix and mapped directly to
|
||||
/// struct fields via case-insensitive matching (e.g. `IMAP_HOST` → `imap_host`).
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn load() -> Result<Self, figment::Error> {
|
||||
let path = std::env::var("RUSTBOX_CONFIG")
|
||||
.unwrap_or_else(|_| "/etc/rustbox/rustbox.toml".to_string());
|
||||
|
||||
let mut figment = Figment::new();
|
||||
if std::path::Path::new(&path).is_file() {
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self
|
||||
.imap_host
|
||||
.as_deref()
|
||||
.map(|host| host.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return Err("IMAP_HOST / imap_host must be configured".to_string());
|
||||
}
|
||||
if self.port == 0 || self.imap_port == 0 || self.smtp_port == 0 || self.sieve_port == 0 {
|
||||
return Err("network ports must be greater than zero".to_string());
|
||||
}
|
||||
if self.session_timeout_hours == 0 || self.session_timeout_hours > 24 * 365 {
|
||||
return Err("SESSION_TIMEOUT_HOURS must be between 1 and 8760".to_string());
|
||||
}
|
||||
if self.remember_me_days == 0 || self.remember_me_days > 365 {
|
||||
return Err("REMEMBER_ME_DAYS must be between 1 and 365".to_string());
|
||||
}
|
||||
if !matches!(
|
||||
self.sieve_tls_mode.as_str(),
|
||||
"starttls" | "implicit" | "none"
|
||||
) {
|
||||
return Err("SIEVE_TLS_MODE must be one of: starttls, implicit, none".to_string());
|
||||
}
|
||||
if self.sieve_tls_mode == "none" && !self.sieve_allow_insecure_auth {
|
||||
tracing::warn!(
|
||||
"ManageSieve plaintext transport selected; authentication will remain blocked until SIEVE_ALLOW_INSECURE_AUTH=true"
|
||||
);
|
||||
}
|
||||
self.parsed_outgoing_headers()?;
|
||||
if self.pgp_network_timeout_secs == 0 || self.pgp_network_timeout_secs > 60 {
|
||||
return Err("PGP_NETWORK_TIMEOUT_SECS must be between 1 and 60".to_string());
|
||||
}
|
||||
if self.pgp_max_key_bytes < 16 * 1024 || self.pgp_max_key_bytes > 4 * 1024 * 1024 {
|
||||
return Err("PGP_MAX_KEY_BYTES must be between 16384 and 4194304".to_string());
|
||||
}
|
||||
if self.pgp_hkp_enabled {
|
||||
let url = url::Url::parse(&self.pgp_hkp_url)
|
||||
.map_err(|e| format!("invalid PGP_HKP_URL: {e}"))?;
|
||||
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
|
||||
return Err("PGP_HKP_URL must be a credential-free HTTPS URL".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_values_load_correctly() {
|
||||
// With no env vars set, all defaults should apply.
|
||||
// We use figment with no providers (only serde defaults).
|
||||
let config: AppConfig = Figment::new().extract().expect("defaults should load");
|
||||
|
||||
assert_eq!(config.host, "0.0.0.0");
|
||||
assert_eq!(config.port, 3001);
|
||||
assert!(config.imap_host.is_none());
|
||||
assert_eq!(config.imap_port, 993);
|
||||
assert!(config.smtp_host.is_none());
|
||||
assert_eq!(config.smtp_port, 587);
|
||||
assert!(config.tls_enabled);
|
||||
assert_eq!(config.data_dir, "/data");
|
||||
assert_eq!(config.session_timeout_hours, 24);
|
||||
assert!(config.remember_me_enabled);
|
||||
assert_eq!(config.remember_me_days, 30);
|
||||
assert_eq!(config.environment, "development");
|
||||
assert!(config.sieve_host.is_none());
|
||||
assert_eq!(config.sieve_port, 4190);
|
||||
assert_eq!(config.sieve_tls_mode, "starttls");
|
||||
assert!(!config.sieve_allow_insecure_auth);
|
||||
assert_eq!(config.sieve_timeout_secs, 10);
|
||||
assert!(config.trusted_proxies.is_none());
|
||||
assert!(config.outgoing_headers.is_empty());
|
||||
assert!(!config.pgp_allow_private_export);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_var_overrides_work() {
|
||||
// Simulate env overrides via figment's tuple provider
|
||||
// to verify all fields are overridable.
|
||||
let config: AppConfig = Figment::new()
|
||||
.merge(("host", "127.0.0.1"))
|
||||
.merge(("port", 8080u16))
|
||||
.merge(("imap_host", "mail.example.com"))
|
||||
.merge(("imap_port", 143u16))
|
||||
.merge(("smtp_host", "smtp.example.com"))
|
||||
.merge(("smtp_port", 465u16))
|
||||
.merge(("tls_enabled", false))
|
||||
.merge(("data_dir", "/var/rustbox"))
|
||||
.merge(("session_timeout_hours", 48u64))
|
||||
.merge(("remember_me_enabled", false))
|
||||
.merge(("remember_me_days", 14u64))
|
||||
.merge(("environment", "production"))
|
||||
.merge(("sieve_host", "sieve.example.com"))
|
||||
.merge(("sieve_port", 4191u16))
|
||||
.merge(("sieve_tls_mode", "implicit"))
|
||||
.merge(("sieve_allow_insecure_auth", true))
|
||||
.merge(("sieve_timeout_secs", 15u64))
|
||||
.merge(("trusted_proxies", "127.0.0.1,::1"))
|
||||
.extract()
|
||||
.expect("overrides should load");
|
||||
|
||||
assert_eq!(config.host, "127.0.0.1");
|
||||
assert_eq!(config.port, 8080);
|
||||
assert_eq!(config.imap_host.as_deref(), Some("mail.example.com"));
|
||||
assert_eq!(config.imap_port, 143);
|
||||
assert_eq!(config.smtp_host.as_deref(), Some("smtp.example.com"));
|
||||
assert_eq!(config.smtp_port, 465);
|
||||
assert!(!config.tls_enabled);
|
||||
assert_eq!(config.data_dir, "/var/rustbox");
|
||||
assert_eq!(config.session_timeout_hours, 48);
|
||||
assert!(!config.remember_me_enabled);
|
||||
assert_eq!(config.remember_me_days, 14);
|
||||
assert_eq!(config.environment, "production");
|
||||
assert_eq!(config.sieve_host.as_deref(), Some("sieve.example.com"));
|
||||
assert_eq!(config.sieve_port, 4191);
|
||||
assert_eq!(config.sieve_tls_mode, "implicit");
|
||||
assert!(config.sieve_allow_insecure_auth);
|
||||
assert_eq!(config.sieve_timeout_secs, 15);
|
||||
assert_eq!(config.trusted_proxies.as_deref(), Some("127.0.0.1,::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_env_vars_override_multi_word_fields() {
|
||||
// Verify that actual environment variables with underscores
|
||||
// (e.g. IMAP_HOST) correctly map to struct fields (imap_host).
|
||||
// SAFETY: This test runs single-threaded (--test-threads=1)
|
||||
// and cleans up env vars after use.
|
||||
unsafe {
|
||||
std::env::set_var("IMAP_HOST", "test-imap.example.com");
|
||||
std::env::set_var("DATA_DIR", "/tmp/oxi-test");
|
||||
}
|
||||
|
||||
let config = AppConfig::load().expect("load with env vars should succeed");
|
||||
|
||||
assert_eq!(config.imap_host.as_deref(), Some("test-imap.example.com"));
|
||||
assert_eq!(config.data_dir, "/tmp/oxi-test");
|
||||
|
||||
// Clean up
|
||||
unsafe {
|
||||
std::env::remove_var("IMAP_HOST");
|
||||
std::env::remove_var("DATA_DIR");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_headers_are_parsed_and_structural_headers_rejected() {
|
||||
let config = AppConfig {
|
||||
outgoing_headers: vec![
|
||||
"X-Company-Mailer: RustBox".to_string(),
|
||||
"X-Environment: production".to_string(),
|
||||
],
|
||||
..Figment::new().extract().expect("defaults should load")
|
||||
};
|
||||
assert_eq!(
|
||||
config.parsed_outgoing_headers().unwrap(),
|
||||
vec![
|
||||
("X-Company-Mailer".to_string(), "RustBox".to_string()),
|
||||
("X-Environment".to_string(), "production".to_string()),
|
||||
]
|
||||
);
|
||||
|
||||
let reserved = AppConfig {
|
||||
outgoing_headers: vec!["Subject: injected".to_string()],
|
||||
..Figment::new().extract().expect("defaults should load")
|
||||
};
|
||||
assert!(reserved.parsed_outgoing_headers().is_err());
|
||||
|
||||
let injected = AppConfig {
|
||||
outgoing_headers: vec!["X-Test: ok\r\nBcc: hidden@example.com".to_string()],
|
||||
..Figment::new().extract().expect("defaults should load")
|
||||
};
|
||||
assert!(injected.parsed_outgoing_headers().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_trusted_proxies_accepts_individual_ip() {
|
||||
let config = AppConfig {
|
||||
trusted_proxies: Some("127.0.0.1".to_string()),
|
||||
..Figment::new().extract().expect("defaults should load")
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.parsed_trusted_proxies(),
|
||||
vec![TrustedProxy::Ip("127.0.0.1".parse().unwrap())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_trusted_proxies_matches_cidr() {
|
||||
let config = AppConfig {
|
||||
trusted_proxies: Some("10.0.0.0/8".to_string()),
|
||||
..Figment::new().extract().expect("defaults should load")
|
||||
};
|
||||
|
||||
let parsed = config.parsed_trusted_proxies();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert!(parsed[0].contains(&"10.1.2.3".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_trusted_proxies_does_not_match_outside_cidr() {
|
||||
let config = AppConfig {
|
||||
trusted_proxies: Some("10.0.0.0/8".to_string()),
|
||||
..Figment::new().extract().expect("defaults should load")
|
||||
};
|
||||
|
||||
let parsed = config.parsed_trusted_proxies();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert!(!parsed[0].contains(&"192.168.1.1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_trusted_proxies_ignores_invalid_entries() {
|
||||
let config = AppConfig {
|
||||
trusted_proxies: Some("bad-entry,127.0.0.1,10.0.0.0/8/extra".to_string()),
|
||||
..Figment::new().extract().expect("defaults should load")
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.parsed_trusted_proxies(),
|
||||
vec![TrustedProxy::Ip("127.0.0.1".parse().unwrap())]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A calendar event record, mirroring the `calendar_events` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CalendarEvent {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub location: String,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub all_day: bool,
|
||||
pub recurrence_rule: Option<String>,
|
||||
pub attendees: String,
|
||||
pub organizer: String,
|
||||
pub status: String,
|
||||
pub source: String,
|
||||
pub source_uid: Option<String>,
|
||||
pub source_message_uid: Option<i64>,
|
||||
pub source_message_folder: Option<String>,
|
||||
pub meeting_url: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub reminder_minutes: Option<i64>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Fields for creating a new calendar event.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateEvent {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub location: String,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
#[serde(default)]
|
||||
pub all_day: bool,
|
||||
pub recurrence_rule: Option<String>,
|
||||
#[serde(default = "default_attendees")]
|
||||
pub attendees: String,
|
||||
#[serde(default)]
|
||||
pub organizer: String,
|
||||
#[serde(default = "default_status")]
|
||||
pub status: String,
|
||||
#[serde(default = "default_source")]
|
||||
pub source: String,
|
||||
pub source_uid: Option<String>,
|
||||
pub source_message_uid: Option<i64>,
|
||||
pub source_message_folder: Option<String>,
|
||||
pub meeting_url: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub reminder_minutes: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_attendees() -> String {
|
||||
"[]".to_string()
|
||||
}
|
||||
|
||||
fn default_status() -> String {
|
||||
"confirmed".to_string()
|
||||
}
|
||||
|
||||
fn default_source() -> String {
|
||||
"manual".to_string()
|
||||
}
|
||||
|
||||
/// Fields for updating an existing calendar event. All optional.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateEvent {
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
pub all_day: Option<bool>,
|
||||
pub recurrence_rule: Option<String>,
|
||||
pub attendees: Option<String>,
|
||||
pub organizer: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub meeting_url: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub reminder_minutes: Option<i64>,
|
||||
}
|
||||
|
||||
/// Calendar settings record, mirroring the `calendar_settings` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CalendarSettings {
|
||||
pub default_view: String,
|
||||
pub week_starts_on: i64,
|
||||
pub working_hours_start: String,
|
||||
pub working_hours_end: String,
|
||||
pub time_format: String,
|
||||
pub zoom_link: String,
|
||||
pub teams_link: String,
|
||||
pub meet_link: String,
|
||||
pub caldav_url: String,
|
||||
pub caldav_username: String,
|
||||
pub caldav_enabled: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Fields for updating calendar settings. All optional.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCalendarSettings {
|
||||
pub default_view: Option<String>,
|
||||
pub week_starts_on: Option<i64>,
|
||||
pub working_hours_start: Option<String>,
|
||||
pub working_hours_end: Option<String>,
|
||||
pub time_format: Option<String>,
|
||||
pub zoom_link: Option<String>,
|
||||
pub teams_link: Option<String>,
|
||||
pub meet_link: Option<String>,
|
||||
pub caldav_url: Option<String>,
|
||||
pub caldav_username: Option<String>,
|
||||
pub caldav_password: Option<String>,
|
||||
pub caldav_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// A meeting template record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingTemplate {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub url_template: String,
|
||||
pub icon: String,
|
||||
pub is_default: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row mappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EVENT_SELECT_COLS: &str = "id, title, description, location, start_time, end_time, \
|
||||
all_day, recurrence_rule, attendees, organizer, status, source, source_uid, \
|
||||
source_message_uid, source_message_folder, meeting_url, color, reminder_minutes, \
|
||||
created_at, updated_at";
|
||||
|
||||
fn row_to_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<CalendarEvent> {
|
||||
let all_day_int: i32 = row.get(6)?;
|
||||
Ok(CalendarEvent {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
location: row.get(3)?,
|
||||
start_time: row.get(4)?,
|
||||
end_time: row.get(5)?,
|
||||
all_day: all_day_int != 0,
|
||||
recurrence_rule: row.get(7)?,
|
||||
attendees: row.get(8)?,
|
||||
organizer: row.get(9)?,
|
||||
status: row.get(10)?,
|
||||
source: row.get(11)?,
|
||||
source_uid: row.get(12)?,
|
||||
source_message_uid: row.get(13)?,
|
||||
source_message_folder: row.get(14)?,
|
||||
meeting_url: row.get(15)?,
|
||||
color: row.get(16)?,
|
||||
reminder_minutes: row.get(17)?,
|
||||
created_at: row.get(18)?,
|
||||
updated_at: row.get(19)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_settings(row: &rusqlite::Row<'_>) -> rusqlite::Result<CalendarSettings> {
|
||||
let caldav_enabled_int: i32 = row.get(10)?;
|
||||
Ok(CalendarSettings {
|
||||
default_view: row.get(0)?,
|
||||
week_starts_on: row.get(1)?,
|
||||
working_hours_start: row.get(2)?,
|
||||
working_hours_end: row.get(3)?,
|
||||
time_format: row.get(4)?,
|
||||
zoom_link: row.get(5)?,
|
||||
teams_link: row.get(6)?,
|
||||
meet_link: row.get(7)?,
|
||||
caldav_url: row.get(8)?,
|
||||
caldav_username: row.get(9)?,
|
||||
caldav_enabled: caldav_enabled_int != 0,
|
||||
updated_at: row.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_template(row: &rusqlite::Row<'_>) -> rusqlite::Result<MeetingTemplate> {
|
||||
let is_default_int: i32 = row.get(4)?;
|
||||
Ok(MeetingTemplate {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
url_template: row.get(2)?,
|
||||
icon: row.get(3)?,
|
||||
is_default: is_default_int != 0,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List events in the given time range (inclusive start, inclusive end).
|
||||
pub fn list_events(
|
||||
conn: &Connection,
|
||||
start: &str,
|
||||
end: &str,
|
||||
) -> Result<Vec<CalendarEvent>, String> {
|
||||
let sql = format!(
|
||||
"SELECT {EVENT_SELECT_COLS} FROM calendar_events
|
||||
WHERE end_time >= ?1 AND start_time <= ?2
|
||||
ORDER BY start_time ASC"
|
||||
);
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare list_events: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![start, end], row_to_event)
|
||||
.map_err(|e| format!("Failed to query events: {e}"))?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
for row in rows {
|
||||
events.push(row.map_err(|e| format!("Failed to read event row: {e}"))?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// List all calendar events ordered by start time.
|
||||
pub fn list_all_events(conn: &Connection) -> Result<Vec<CalendarEvent>, String> {
|
||||
let sql = format!(
|
||||
"SELECT {EVENT_SELECT_COLS} FROM calendar_events ORDER BY start_time ASC, title ASC"
|
||||
);
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare list_all_events: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], row_to_event)
|
||||
.map_err(|e| format!("Failed to query all calendar events: {e}"))?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
for row in rows {
|
||||
events.push(row.map_err(|e| format!("Failed to read calendar event: {e}"))?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Get a single event by ID.
|
||||
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 result = conn.query_row(&sql, params![id], row_to_event);
|
||||
|
||||
match result {
|
||||
Ok(e) => Ok(Some(e)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get event: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new calendar event. Generates a UUID for the ID.
|
||||
pub fn create_event(conn: &Connection, data: &CreateEvent) -> Result<CalendarEvent, String> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now: String = conn
|
||||
.query_row("SELECT datetime('now')", [], |row| row.get(0))
|
||||
.map_err(|e| format!("Failed to get current time: {e}"))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO calendar_events
|
||||
(id, title, description, location, start_time, end_time, all_day,
|
||||
recurrence_rule, attendees, organizer, status, source, source_uid,
|
||||
source_message_uid, source_message_folder, meeting_url, color,
|
||||
reminder_minutes, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||
params![
|
||||
id,
|
||||
data.title,
|
||||
data.description,
|
||||
data.location,
|
||||
data.start_time,
|
||||
data.end_time,
|
||||
data.all_day as i32,
|
||||
data.recurrence_rule,
|
||||
data.attendees,
|
||||
data.organizer,
|
||||
data.status,
|
||||
data.source,
|
||||
data.source_uid,
|
||||
data.source_message_uid,
|
||||
data.source_message_folder,
|
||||
data.meeting_url,
|
||||
data.color,
|
||||
data.reminder_minutes,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create event: {e}"))?;
|
||||
|
||||
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(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
data: &UpdateEvent,
|
||||
) -> Result<Option<CalendarEvent>, String> {
|
||||
// Build SET clauses dynamically for non-None fields.
|
||||
let mut sets = Vec::new();
|
||||
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let mut idx = 1u32;
|
||||
|
||||
macro_rules! maybe_set {
|
||||
($field:ident, $col:expr) => {
|
||||
if let Some(ref v) = data.$field {
|
||||
sets.push(format!("{} = ?{idx}", $col));
|
||||
values.push(Box::new(v.clone()));
|
||||
idx += 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
maybe_set!(title, "title");
|
||||
maybe_set!(description, "description");
|
||||
maybe_set!(location, "location");
|
||||
maybe_set!(start_time, "start_time");
|
||||
maybe_set!(end_time, "end_time");
|
||||
maybe_set!(attendees, "attendees");
|
||||
maybe_set!(organizer, "organizer");
|
||||
maybe_set!(status, "status");
|
||||
maybe_set!(meeting_url, "meeting_url");
|
||||
maybe_set!(color, "color");
|
||||
maybe_set!(recurrence_rule, "recurrence_rule");
|
||||
|
||||
if let Some(v) = data.all_day {
|
||||
sets.push(format!("all_day = ?{idx}"));
|
||||
values.push(Box::new(v as i32));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if let Some(v) = data.reminder_minutes {
|
||||
sets.push(format!("reminder_minutes = ?{idx}"));
|
||||
values.push(Box::new(v));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if sets.is_empty() {
|
||||
return get_event(conn, id);
|
||||
}
|
||||
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE calendar_events SET {} WHERE id = ?{idx}",
|
||||
sets.join(", ")
|
||||
);
|
||||
values.push(Box::new(id.to_string()));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|b| b.as_ref()).collect();
|
||||
let updated = conn
|
||||
.execute(&sql, params_refs.as_slice())
|
||||
.map_err(|e| format!("Failed to update event: {e}"))?;
|
||||
|
||||
if updated == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
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> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM calendar_events WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete event: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Find an event by its source_uid (for deduplication during ICS import).
|
||||
pub fn find_event_by_source_uid(
|
||||
conn: &Connection,
|
||||
source_uid: &str,
|
||||
) -> Result<Option<CalendarEvent>, String> {
|
||||
let sql = format!("SELECT {EVENT_SELECT_COLS} FROM calendar_events WHERE source_uid = ?1");
|
||||
let result = conn.query_row(&sql, params![source_uid], row_to_event);
|
||||
|
||||
match result {
|
||||
Ok(e) => Ok(Some(e)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to find event by source_uid: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SETTINGS_SELECT_COLS: &str = "default_view, week_starts_on, working_hours_start, \
|
||||
working_hours_end, time_format, zoom_link, teams_link, meet_link, caldav_url, \
|
||||
caldav_username, caldav_enabled, updated_at";
|
||||
|
||||
/// Get calendar settings, inserting a default row if none exists.
|
||||
pub fn get_calendar_settings(conn: &Connection) -> Result<CalendarSettings, String> {
|
||||
// Ensure the singleton row exists.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?;
|
||||
|
||||
let sql = format!("SELECT {SETTINGS_SELECT_COLS} FROM calendar_settings WHERE id = 1");
|
||||
conn.query_row(&sql, [], row_to_settings)
|
||||
.map_err(|e| format!("Failed to get calendar settings: {e}"))
|
||||
}
|
||||
|
||||
/// Update calendar settings. Returns the updated settings.
|
||||
pub fn update_calendar_settings(
|
||||
conn: &Connection,
|
||||
data: &UpdateCalendarSettings,
|
||||
) -> Result<CalendarSettings, String> {
|
||||
// Ensure the singleton row exists.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO calendar_settings (id) VALUES (1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to ensure calendar_settings row: {e}"))?;
|
||||
|
||||
let mut sets = Vec::new();
|
||||
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let mut idx = 1u32;
|
||||
|
||||
macro_rules! maybe_set {
|
||||
($field:ident, $col:expr) => {
|
||||
if let Some(ref v) = data.$field {
|
||||
sets.push(format!("{} = ?{idx}", $col));
|
||||
values.push(Box::new(v.clone()));
|
||||
idx += 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
maybe_set!(default_view, "default_view");
|
||||
maybe_set!(working_hours_start, "working_hours_start");
|
||||
maybe_set!(working_hours_end, "working_hours_end");
|
||||
maybe_set!(time_format, "time_format");
|
||||
maybe_set!(zoom_link, "zoom_link");
|
||||
maybe_set!(teams_link, "teams_link");
|
||||
maybe_set!(meet_link, "meet_link");
|
||||
maybe_set!(caldav_url, "caldav_url");
|
||||
maybe_set!(caldav_username, "caldav_username");
|
||||
maybe_set!(caldav_password, "caldav_password");
|
||||
|
||||
if let Some(v) = data.week_starts_on {
|
||||
sets.push(format!("week_starts_on = ?{idx}"));
|
||||
values.push(Box::new(v));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if let Some(v) = data.caldav_enabled {
|
||||
sets.push(format!("caldav_enabled = ?{idx}"));
|
||||
values.push(Box::new(v as i32));
|
||||
#[allow(unused_assignments)]
|
||||
{
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !sets.is_empty() {
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
let sql = format!(
|
||||
"UPDATE calendar_settings SET {} WHERE id = 1",
|
||||
sets.join(", ")
|
||||
);
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
values.iter().map(|b| b.as_ref()).collect();
|
||||
conn.execute(&sql, params_refs.as_slice())
|
||||
.map_err(|e| format!("Failed to update calendar settings: {e}"))?;
|
||||
}
|
||||
|
||||
get_calendar_settings(conn)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — Meeting Templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List all meeting templates.
|
||||
pub fn list_meeting_templates(conn: &Connection) -> Result<Vec<MeetingTemplate>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, url_template, icon, is_default, created_at
|
||||
FROM meeting_templates ORDER BY is_default DESC, name ASC",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_meeting_templates: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], row_to_template)
|
||||
.map_err(|e| format!("Failed to query meeting templates: {e}"))?;
|
||||
|
||||
let mut templates = Vec::new();
|
||||
for row in rows {
|
||||
templates.push(row.map_err(|e| format!("Failed to read template row: {e}"))?);
|
||||
}
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// Create a new meeting template.
|
||||
pub fn create_meeting_template(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
url_template: &str,
|
||||
icon: &str,
|
||||
) -> Result<MeetingTemplate, String> {
|
||||
conn.execute(
|
||||
"INSERT INTO meeting_templates (name, url_template, icon) VALUES (?1, ?2, ?3)",
|
||||
params![name, url_template, icon],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create meeting template: {e}"))?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
let sql = "SELECT id, name, url_template, icon, is_default, created_at
|
||||
FROM meeting_templates WHERE id = ?1";
|
||||
conn.query_row(sql, params![id], row_to_template)
|
||||
.map_err(|e| format!("Failed to read created template: {e}"))
|
||||
}
|
||||
|
||||
/// Delete a meeting template by ID. Returns true if a row was deleted.
|
||||
pub fn delete_meeting_template(conn: &Connection, id: i64) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM meeting_templates WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete meeting template: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_get_event() {
|
||||
let conn = open_test_db();
|
||||
let data = CreateEvent {
|
||||
title: "Team Standup".to_string(),
|
||||
description: "Daily standup meeting".to_string(),
|
||||
location: "Room A".to_string(),
|
||||
start_time: "2025-03-06T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-06T09:30:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: "alice@example.com".to_string(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "manual".to_string(),
|
||||
source_uid: None,
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: Some("https://zoom.us/j/123".to_string()),
|
||||
color: Some("blue".to_string()),
|
||||
reminder_minutes: Some(15),
|
||||
};
|
||||
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
assert_eq!(event.title, "Team Standup");
|
||||
assert_eq!(event.location, "Room A");
|
||||
assert!(!event.all_day);
|
||||
|
||||
let fetched = get_event(&conn, &event.id).unwrap();
|
||||
assert!(fetched.is_some());
|
||||
assert_eq!(fetched.unwrap().title, "Team Standup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_events_range() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let early = CreateEvent {
|
||||
title: "Early".to_string(),
|
||||
description: String::new(),
|
||||
location: String::new(),
|
||||
start_time: "2025-03-01T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-01T10:00:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: String::new(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "manual".to_string(),
|
||||
source_uid: None,
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
let late = CreateEvent {
|
||||
title: "Late".to_string(),
|
||||
start_time: "2025-04-01T09:00:00Z".to_string(),
|
||||
end_time: "2025-04-01T10:00:00Z".to_string(),
|
||||
..early.clone_fields()
|
||||
};
|
||||
|
||||
create_event(&conn, &early).unwrap();
|
||||
create_event(&conn, &late).unwrap();
|
||||
|
||||
let march = list_events(&conn, "2025-03-01T00:00:00Z", "2025-03-31T23:59:59Z").unwrap();
|
||||
assert_eq!(march.len(), 1);
|
||||
assert_eq!(march[0].title, "Early");
|
||||
|
||||
let all = list_events(&conn, "2025-01-01T00:00:00Z", "2025-12-31T23:59:59Z").unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_event() {
|
||||
let conn = open_test_db();
|
||||
let data = CreateEvent {
|
||||
title: "Original".to_string(),
|
||||
description: String::new(),
|
||||
location: String::new(),
|
||||
start_time: "2025-03-06T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-06T10:00:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: String::new(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "manual".to_string(),
|
||||
source_uid: None,
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
let update = UpdateEvent {
|
||||
title: Some("Updated".to_string()),
|
||||
description: None,
|
||||
location: None,
|
||||
start_time: None,
|
||||
end_time: None,
|
||||
all_day: None,
|
||||
recurrence_rule: None,
|
||||
attendees: None,
|
||||
organizer: None,
|
||||
status: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
let updated = update_event(&conn, &event.id, &update).unwrap();
|
||||
assert!(updated.is_some());
|
||||
assert_eq!(updated.unwrap().title, "Updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_event() {
|
||||
let conn = open_test_db();
|
||||
let data = CreateEvent {
|
||||
title: "To Delete".to_string(),
|
||||
description: String::new(),
|
||||
location: String::new(),
|
||||
start_time: "2025-03-06T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-06T10:00:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: String::new(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "manual".to_string(),
|
||||
source_uid: None,
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
let event = create_event(&conn, &data).unwrap();
|
||||
assert!(delete_event(&conn, &event.id).unwrap());
|
||||
assert!(!delete_event(&conn, &event.id).unwrap());
|
||||
assert!(get_event(&conn, &event.id).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_event_by_source_uid() {
|
||||
let conn = open_test_db();
|
||||
let data = CreateEvent {
|
||||
title: "Imported".to_string(),
|
||||
description: String::new(),
|
||||
location: String::new(),
|
||||
start_time: "2025-03-06T09:00:00Z".to_string(),
|
||||
end_time: "2025-03-06T10:00:00Z".to_string(),
|
||||
all_day: false,
|
||||
recurrence_rule: None,
|
||||
attendees: "[]".to_string(),
|
||||
organizer: String::new(),
|
||||
status: "confirmed".to_string(),
|
||||
source: "ics".to_string(),
|
||||
source_uid: Some("unique-uid-123@example.com".to_string()),
|
||||
source_message_uid: None,
|
||||
source_message_folder: None,
|
||||
meeting_url: None,
|
||||
color: None,
|
||||
reminder_minutes: None,
|
||||
};
|
||||
|
||||
create_event(&conn, &data).unwrap();
|
||||
|
||||
let found = find_event_by_source_uid(&conn, "unique-uid-123@example.com").unwrap();
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().title, "Imported");
|
||||
|
||||
let missing = find_event_by_source_uid(&conn, "nonexistent@example.com").unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calendar_settings() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let settings = get_calendar_settings(&conn).unwrap();
|
||||
assert_eq!(settings.default_view, "month");
|
||||
assert_eq!(settings.week_starts_on, 0);
|
||||
assert_eq!(settings.time_format, "12h");
|
||||
|
||||
let update = UpdateCalendarSettings {
|
||||
default_view: Some("week".to_string()),
|
||||
week_starts_on: Some(1),
|
||||
working_hours_start: None,
|
||||
working_hours_end: None,
|
||||
time_format: Some("24h".to_string()),
|
||||
zoom_link: None,
|
||||
teams_link: None,
|
||||
meet_link: None,
|
||||
caldav_url: None,
|
||||
caldav_username: None,
|
||||
caldav_password: None,
|
||||
caldav_enabled: None,
|
||||
};
|
||||
|
||||
let updated = update_calendar_settings(&conn, &update).unwrap();
|
||||
assert_eq!(updated.default_view, "week");
|
||||
assert_eq!(updated.week_starts_on, 1);
|
||||
assert_eq!(updated.time_format, "24h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meeting_templates() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let template =
|
||||
create_meeting_template(&conn, "Zoom", "https://zoom.us/j/{id}", "video").unwrap();
|
||||
assert_eq!(template.name, "Zoom");
|
||||
|
||||
let all = list_meeting_templates(&conn).unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
|
||||
assert!(delete_meeting_template(&conn, template.id).unwrap());
|
||||
assert!(!delete_meeting_template(&conn, template.id).unwrap());
|
||||
|
||||
let all = list_meeting_templates(&conn).unwrap();
|
||||
assert_eq!(all.len(), 0);
|
||||
}
|
||||
|
||||
// Helper to create CreateEvent variants without full Clone impl
|
||||
impl CreateEvent {
|
||||
fn clone_fields(&self) -> Self {
|
||||
CreateEvent {
|
||||
title: String::new(),
|
||||
description: self.description.clone(),
|
||||
location: self.location.clone(),
|
||||
start_time: String::new(),
|
||||
end_time: String::new(),
|
||||
all_day: self.all_day,
|
||||
recurrence_rule: self.recurrence_rule.clone(),
|
||||
attendees: self.attendees.clone(),
|
||||
organizer: self.organizer.clone(),
|
||||
status: self.status.clone(),
|
||||
source: self.source.clone(),
|
||||
source_uid: self.source_uid.clone(),
|
||||
source_message_uid: self.source_message_uid,
|
||||
source_message_folder: self.source_message_folder.clone(),
|
||||
meeting_url: self.meeting_url.clone(),
|
||||
color: self.color.clone(),
|
||||
reminder_minutes: self.reminder_minutes,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::contacts::Contact;
|
||||
|
||||
/// A contact group record, mirroring the `contact_groups` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ContactGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub member_count: i64,
|
||||
pub created_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> {
|
||||
conn.execute(
|
||||
"INSERT INTO contact_groups (id, name) VALUES (?1, ?2)",
|
||||
params![id, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create contact group: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all contact groups with their member counts.
|
||||
pub fn list_groups(conn: &Connection) -> Result<Vec<ContactGroup>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT g.id, g.name, g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM contact_group_members m WHERE m.group_id = g.id) AS member_count
|
||||
FROM contact_groups g
|
||||
ORDER BY g.name ASC",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_groups: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(ContactGroup {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
member_count: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query groups: {e}"))?;
|
||||
|
||||
let mut groups = Vec::new();
|
||||
for row in rows {
|
||||
groups.push(row.map_err(|e| format!("Failed to read group row: {e}"))?);
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// Update a group's name.
|
||||
pub fn update_group(conn: &Connection, id: &str, name: &str) -> Result<bool, String> {
|
||||
let updated = conn
|
||||
.execute(
|
||||
"UPDATE contact_groups SET name = ?1, updated_at = datetime('now') WHERE id = ?2",
|
||||
params![name, id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update contact group: {e}"))?;
|
||||
Ok(updated > 0)
|
||||
}
|
||||
|
||||
/// Delete a contact group. CASCADE handles member rows.
|
||||
pub fn delete_group(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM contact_groups WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete contact group: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Add a contact to a group (INSERT OR IGNORE to avoid duplicates).
|
||||
pub fn add_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO contact_group_members (group_id, contact_id) VALUES (?1, ?2)",
|
||||
params![group_id, contact_id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add group member: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a contact from a group.
|
||||
pub fn remove_member(conn: &Connection, group_id: &str, contact_id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM contact_group_members WHERE group_id = ?1 AND contact_id = ?2",
|
||||
params![group_id, contact_id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to remove group member: {e}"))?;
|
||||
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> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT c.id, c.email, c.name, c.company, c.notes, c.phone, c.job_title, c.address, c.website, c.birthday, c.is_favorite,
|
||||
c.last_contacted, c.contact_count, c.source, c.created_at, c.updated_at, c.emails_json, c.phones_json, c.addresses_json, EXISTS(SELECT 1 FROM contact_photos p WHERE p.contact_id = c.id) AS has_photo
|
||||
FROM contacts c
|
||||
JOIN contact_group_members m ON m.contact_id = c.id
|
||||
WHERE m.group_id = ?1
|
||||
ORDER BY c.name ASC",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_group_members: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![group_id], |row| {
|
||||
Ok(Contact {
|
||||
id: row.get(0)?,
|
||||
email: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
company: row.get(3)?,
|
||||
notes: row.get(4)?,
|
||||
phone: row.get(5)?,
|
||||
job_title: row.get(6)?,
|
||||
address: row.get(7)?,
|
||||
website: row.get(8)?,
|
||||
birthday: row.get(9)?,
|
||||
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(),
|
||||
addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?)
|
||||
.unwrap_or_default(),
|
||||
is_favorite: row.get::<_, i32>(10)? != 0,
|
||||
last_contacted: row.get(11)?,
|
||||
contact_count: row.get(12)?,
|
||||
source: row.get(13)?,
|
||||
created_at: row.get(14)?,
|
||||
updated_at: row.get(15)?,
|
||||
has_photo: row.get::<_, i32>(19)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query group members: {e}"))?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
contacts.push(row.map_err(|e| format!("Failed to read group member row: {e}"))?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// List all groups a given contact belongs to.
|
||||
pub fn list_contact_groups(
|
||||
conn: &Connection,
|
||||
contact_id: &str,
|
||||
) -> Result<Vec<ContactGroup>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT g.id, g.name, g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM contact_group_members m2 WHERE m2.group_id = g.id) AS member_count
|
||||
FROM contact_groups g
|
||||
JOIN contact_group_members m ON m.group_id = g.id
|
||||
WHERE m.contact_id = ?1
|
||||
ORDER BY g.name ASC",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_contact_groups: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![contact_id], |row| {
|
||||
Ok(ContactGroup {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
member_count: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query contact groups: {e}"))?;
|
||||
|
||||
let mut groups = Vec::new();
|
||||
for row in rows {
|
||||
groups.push(row.map_err(|e| format!("Failed to read contact group row: {e}"))?);
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::contacts::{Contact as DbContact, upsert_contact};
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
fn sample_contact(id: &str, email: &str, name: &str) -> DbContact {
|
||||
DbContact {
|
||||
id: id.to_string(),
|
||||
email: email.to_string(),
|
||||
name: name.to_string(),
|
||||
company: String::new(),
|
||||
notes: String::new(),
|
||||
phone: String::new(),
|
||||
job_title: String::new(),
|
||||
address: String::new(),
|
||||
website: String::new(),
|
||||
birthday: String::new(),
|
||||
emails: Vec::new(),
|
||||
phones: Vec::new(),
|
||||
addresses: Vec::new(),
|
||||
is_favorite: false,
|
||||
last_contacted: None,
|
||||
contact_count: 0,
|
||||
source: "manual".to_string(),
|
||||
created_at: "2024-01-01 00:00:00".to_string(),
|
||||
updated_at: "2024-01-01 00:00:00".to_string(),
|
||||
has_photo: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_list_groups() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
create_group(&conn, "g2", "Work").unwrap();
|
||||
|
||||
let groups = list_groups(&conn).unwrap();
|
||||
assert_eq!(groups.len(), 2);
|
||||
assert_eq!(groups[0].name, "Friends");
|
||||
assert_eq!(groups[1].name, "Work");
|
||||
assert_eq!(groups[0].member_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_list_members() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@ex.com", "Alice")).unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c2", "bob@ex.com", "Bob")).unwrap();
|
||||
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
add_member(&conn, "g1", "c2").unwrap();
|
||||
// Duplicate add should be ignored
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
|
||||
let members = list_group_members(&conn, "g1").unwrap();
|
||||
assert_eq!(members.len(), 2);
|
||||
|
||||
let groups = list_groups(&conn).unwrap();
|
||||
assert_eq!(groups[0].member_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_member() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@ex.com", "Alice")).unwrap();
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
|
||||
let removed = remove_member(&conn, "g1", "c1").unwrap();
|
||||
assert!(removed);
|
||||
|
||||
let members = list_group_members(&conn, "g1").unwrap();
|
||||
assert_eq!(members.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_group_cascades() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@ex.com", "Alice")).unwrap();
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
|
||||
let deleted = delete_group(&conn, "g1").unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
let groups = list_groups(&conn).unwrap();
|
||||
assert_eq!(groups.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_contact_groups() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
create_group(&conn, "g2", "Work").unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@ex.com", "Alice")).unwrap();
|
||||
add_member(&conn, "g1", "c1").unwrap();
|
||||
add_member(&conn, "g2", "c1").unwrap();
|
||||
|
||||
let groups = list_contact_groups(&conn, "c1").unwrap();
|
||||
assert_eq!(groups.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_group() {
|
||||
let conn = open_test_db();
|
||||
create_group(&conn, "g1", "Friends").unwrap();
|
||||
|
||||
let updated = update_group(&conn, "g1", "Close Friends").unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let groups = list_groups(&conn).unwrap();
|
||||
assert_eq!(groups[0].name, "Close Friends");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A known address extracted from message headers (not necessarily a contact).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct KnownAddress {
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A contact record, mirroring the `contacts` table.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Contact {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub company: String,
|
||||
pub notes: String,
|
||||
pub phone: String,
|
||||
pub job_title: String,
|
||||
pub address: String,
|
||||
pub website: String,
|
||||
pub birthday: String,
|
||||
pub emails: Vec<String>,
|
||||
pub phones: Vec<String>,
|
||||
pub addresses: Vec<String>,
|
||||
pub is_favorite: bool,
|
||||
pub last_contacted: Option<String>,
|
||||
pub contact_count: i64,
|
||||
pub source: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub has_photo: bool,
|
||||
}
|
||||
|
||||
/// Map a rusqlite row to a Contact struct.
|
||||
fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
|
||||
Ok(Contact {
|
||||
id: row.get(0)?,
|
||||
email: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
company: row.get(3)?,
|
||||
notes: row.get(4)?,
|
||||
phone: row.get(5)?,
|
||||
job_title: row.get(6)?,
|
||||
address: row.get(7)?,
|
||||
website: row.get(8)?,
|
||||
birthday: row.get(9)?,
|
||||
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(),
|
||||
addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?).unwrap_or_default(),
|
||||
is_favorite: row.get::<_, i32>(10)? != 0,
|
||||
last_contacted: row.get(11)?,
|
||||
contact_count: row.get(12)?,
|
||||
source: row.get(13)?,
|
||||
created_at: row.get(14)?,
|
||||
updated_at: row.get(15)?,
|
||||
has_photo: row.get::<_, i32>(19)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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";
|
||||
|
||||
/// Escape `%` and `_` in a search term to prevent LIKE wildcard injection.
|
||||
fn escape_like(term: &str) -> String {
|
||||
term.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Insert or replace a contact. All fields are written.
|
||||
pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT INTO contacts
|
||||
(id, email, name, company, notes, phone, job_title, address, website, birthday, is_favorite, last_contacted,
|
||||
contact_count, source, created_at, updated_at, emails_json, phones_json, addresses_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
email=excluded.email, name=excluded.name, company=excluded.company, notes=excluded.notes,
|
||||
phone=excluded.phone, job_title=excluded.job_title, address=excluded.address, website=excluded.website, birthday=excluded.birthday,
|
||||
is_favorite=excluded.is_favorite, last_contacted=excluded.last_contacted, contact_count=excluded.contact_count,
|
||||
source=excluded.source, updated_at=excluded.updated_at, emails_json=excluded.emails_json,
|
||||
phones_json=excluded.phones_json, addresses_json=excluded.addresses_json",
|
||||
params![
|
||||
contact.id,
|
||||
contact.email,
|
||||
contact.name,
|
||||
contact.company,
|
||||
contact.notes,
|
||||
contact.phone,
|
||||
contact.job_title,
|
||||
contact.address,
|
||||
contact.website,
|
||||
contact.birthday,
|
||||
contact.is_favorite as i32,
|
||||
contact.last_contacted,
|
||||
contact.contact_count,
|
||||
contact.source,
|
||||
contact.created_at,
|
||||
contact.updated_at,
|
||||
serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string()),
|
||||
serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string()),
|
||||
serde_json::to_string(&contact.addresses).unwrap_or_else(|_| "[]".to_string()),
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to upsert contact: {e}"))?;
|
||||
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> {
|
||||
let sql = format!("SELECT {CONTACT_SELECT_COLS} FROM contacts WHERE id = ?1");
|
||||
let result = conn.query_row(&sql, params![id], row_to_contact);
|
||||
|
||||
match result {
|
||||
Ok(c) => Ok(Some(c)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get contact: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let sql = format!(
|
||||
"SELECT {CONTACT_SELECT_COLS} FROM contacts \
|
||||
WHERE lower(email) = lower(?1) \
|
||||
OR EXISTS (SELECT 1 FROM json_each(contacts.emails_json) WHERE lower(value) = lower(?1))"
|
||||
);
|
||||
let result = conn.query_row(&sql, params![email], row_to_contact);
|
||||
|
||||
match result {
|
||||
Ok(c) => Ok(Some(c)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get contact by email: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// List contacts with optional search filter on name/email.
|
||||
/// Results are ordered by name ascending, with LIMIT/OFFSET pagination.
|
||||
pub fn list_contacts(
|
||||
conn: &Connection,
|
||||
search: Option<&str>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Contact>, String> {
|
||||
let (sql, has_search) = match search {
|
||||
Some(q) if !q.is_empty() => {
|
||||
let escaped = escape_like(q);
|
||||
let pattern = format!("%{escaped}%");
|
||||
let sql = format!(
|
||||
"SELECT {CONTACT_SELECT_COLS} FROM contacts
|
||||
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
|
||||
ORDER BY name ASC
|
||||
LIMIT ?2 OFFSET ?3"
|
||||
);
|
||||
// We need to pass the pattern, so we store it and handle below.
|
||||
// To keep things simple we branch on has_search.
|
||||
drop(pattern);
|
||||
(sql, Some(escaped))
|
||||
}
|
||||
_ => {
|
||||
let sql = format!(
|
||||
"SELECT {CONTACT_SELECT_COLS} FROM contacts
|
||||
ORDER BY name ASC
|
||||
LIMIT ?1 OFFSET ?2"
|
||||
);
|
||||
(sql, None)
|
||||
}
|
||||
};
|
||||
|
||||
match has_search {
|
||||
Some(escaped) => {
|
||||
let pattern = format!("%{escaped}%");
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare list_contacts: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, limit, offset], row_to_contact)
|
||||
.map_err(|e| format!("Failed to query contacts: {e}"))?;
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare list_contacts: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit, offset], row_to_contact)
|
||||
.map_err(|e| format!("Failed to query contacts: {e}"))?;
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a contact by id. Returns `true` if a row was deleted.
|
||||
pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM contacts WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete contact: {e}"))?;
|
||||
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> {
|
||||
let escaped = escape_like(query);
|
||||
let pattern = format!("%{escaped}%");
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {CONTACT_SELECT_COLS} FROM contacts
|
||||
WHERE name LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR emails_json LIKE ?1 ESCAPE '\\'
|
||||
ORDER BY name ASC
|
||||
LIMIT ?2"
|
||||
);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare search_contacts: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, limit], row_to_contact)
|
||||
.map_err(|e| format!("Failed to query search contacts: {e}"))?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
for row in rows {
|
||||
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
|
||||
}
|
||||
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(
|
||||
conn: &Connection,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<KnownAddress>, String> {
|
||||
let escaped = escape_like(query);
|
||||
let pattern = format!("%{escaped}%");
|
||||
|
||||
let sql = r#"
|
||||
SELECT email, name
|
||||
FROM known_addresses
|
||||
WHERE (email LIKE ?1 ESCAPE '\' OR name LIKE ?1 ESCAPE '\')
|
||||
AND email NOT IN (SELECT email FROM contacts)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM contacts c, json_each(c.emails_json) alt
|
||||
WHERE lower(alt.value) = lower(known_addresses.email)
|
||||
)
|
||||
ORDER BY email ASC
|
||||
LIMIT ?2
|
||||
"#;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(sql)
|
||||
.map_err(|e| format!("Failed to prepare search_known_addresses: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, limit], |row| {
|
||||
Ok(KnownAddress {
|
||||
email: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query known addresses: {e}"))?;
|
||||
|
||||
let mut addresses = Vec::new();
|
||||
for row in rows {
|
||||
addresses.push(row.map_err(|e| format!("Failed to read known address row: {e}"))?);
|
||||
}
|
||||
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(
|
||||
conn: &Connection,
|
||||
from_address: &str,
|
||||
from_name: &str,
|
||||
to_json: &str,
|
||||
cc_json: &str,
|
||||
) -> Result<(), String> {
|
||||
// Insert from address.
|
||||
if !from_address.is_empty() {
|
||||
conn.execute(
|
||||
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
|
||||
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''",
|
||||
params![from_address, from_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert known address (from): {e}"))?;
|
||||
}
|
||||
|
||||
// Insert to addresses from JSON array.
|
||||
insert_addresses_from_json(conn, to_json)?;
|
||||
|
||||
// Insert cc addresses from JSON array.
|
||||
insert_addresses_from_json(conn, cc_json)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a JSON array of `{"address": "...", "name": "..."}` objects and insert
|
||||
/// each into `known_addresses`.
|
||||
fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), String> {
|
||||
let entries: Vec<serde_json::Value> = match serde_json::from_str(json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(()), // Invalid JSON, skip silently.
|
||||
};
|
||||
|
||||
for entry in &entries {
|
||||
let email = entry
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if email.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
conn.execute(
|
||||
"INSERT INTO known_addresses (email, name) VALUES (?1, ?2)
|
||||
ON CONFLICT(email) DO UPDATE SET name = excluded.name WHERE excluded.name != ''",
|
||||
params![email, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert known address (json): {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Increment the contact_count by 1 and set last_contacted to now for the
|
||||
/// contact with the given email address.
|
||||
#[allow(dead_code)]
|
||||
pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE contacts
|
||||
SET contact_count = contact_count + 1,
|
||||
last_contacted = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE email = ?1",
|
||||
params![email],
|
||||
)
|
||||
.map_err(|e| format!("Failed to increment contact count: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Auto-add a contact if one with the same email does not already exist.
|
||||
/// Uses INSERT OR IGNORE so existing contacts are not overwritten.
|
||||
/// Source is set to 'auto'.
|
||||
#[allow(dead_code)]
|
||||
pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO contacts (id, email, name, source)
|
||||
VALUES (?1, ?2, ?3, 'auto')",
|
||||
params![id, email, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to auto-add contact: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_contact_photo(conn: &Connection, contact_id: &str, content_type: &str, data: &[u8]) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT INTO contact_photos (contact_id, content_type, data, updated_at) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(contact_id) DO UPDATE SET content_type=excluded.content_type, data=excluded.data, updated_at=datetime('now')",
|
||||
params![contact_id, content_type, data],
|
||||
).map_err(|e| format!("Failed to save contact photo: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_contact_photo(conn: &Connection, contact_id: &str) -> Result<Option<(String, Vec<u8>)>, String> {
|
||||
match conn.query_row(
|
||||
"SELECT content_type, data FROM contact_photos WHERE contact_id = ?1",
|
||||
params![contact_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to load contact photo: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
.map(|n| n > 0)
|
||||
.map_err(|e| format!("Failed to delete contact photo: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
/// Helper: create a sample Contact with sensible defaults.
|
||||
fn sample_contact(id: &str, email: &str, name: &str) -> Contact {
|
||||
Contact {
|
||||
id: id.to_string(),
|
||||
email: email.to_string(),
|
||||
name: name.to_string(),
|
||||
company: String::new(),
|
||||
notes: String::new(),
|
||||
phone: String::new(),
|
||||
job_title: String::new(),
|
||||
address: String::new(),
|
||||
website: String::new(),
|
||||
birthday: String::new(),
|
||||
emails: Vec::new(),
|
||||
phones: Vec::new(),
|
||||
addresses: Vec::new(),
|
||||
is_favorite: false,
|
||||
last_contacted: None,
|
||||
contact_count: 0,
|
||||
source: "manual".to_string(),
|
||||
created_at: "2024-01-01 00:00:00".to_string(),
|
||||
updated_at: "2024-01-01 00:00:00".to_string(),
|
||||
has_photo: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_and_get_contact() {
|
||||
let conn = open_test_db();
|
||||
let contact = sample_contact("c1", "alice@example.com", "Alice");
|
||||
|
||||
upsert_contact(&conn, &contact).unwrap();
|
||||
|
||||
let fetched = get_contact(&conn, "c1").unwrap();
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.id, "c1");
|
||||
assert_eq!(fetched.email, "alice@example.com");
|
||||
assert_eq!(fetched.name, "Alice");
|
||||
assert_eq!(fetched.source, "manual");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_contact_by_email() {
|
||||
let conn = open_test_db();
|
||||
let contact = sample_contact("c2", "bob@example.com", "Bob");
|
||||
upsert_contact(&conn, &contact).unwrap();
|
||||
|
||||
let fetched = get_contact_by_email(&conn, "bob@example.com").unwrap();
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.id, "c2");
|
||||
assert_eq!(fetched.name, "Bob");
|
||||
|
||||
// Non-existent email returns None.
|
||||
let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_contacts() {
|
||||
let conn = open_test_db();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob")).unwrap();
|
||||
|
||||
let all = list_contacts(&conn, None, 100, 0).unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
// Ordered by name ASC: Alice, Bob.
|
||||
assert_eq!(all[0].name, "Alice");
|
||||
assert_eq!(all[1].name, "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_contacts() {
|
||||
let conn = open_test_db();
|
||||
upsert_contact(
|
||||
&conn,
|
||||
&sample_contact("c1", "alice@example.com", "Alice Smith"),
|
||||
)
|
||||
.unwrap();
|
||||
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob Jones")).unwrap();
|
||||
|
||||
// Search by name should find only Alice.
|
||||
let results = search_contacts(&conn, "Alice", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "Alice Smith");
|
||||
|
||||
// Search by email should find only Bob.
|
||||
let results = search_contacts(&conn, "bob@", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "Bob Jones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_contact() {
|
||||
let conn = open_test_db();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
||||
|
||||
let deleted = delete_contact(&conn, "c1").unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
// Should be gone now.
|
||||
let fetched = get_contact(&conn, "c1").unwrap();
|
||||
assert!(fetched.is_none());
|
||||
|
||||
// Deleting again returns false.
|
||||
let deleted_again = delete_contact(&conn, "c1").unwrap();
|
||||
assert!(!deleted_again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_add_contact() {
|
||||
let conn = open_test_db();
|
||||
|
||||
auto_add_contact(&conn, "auto@example.com", "Auto User").unwrap();
|
||||
|
||||
let fetched = get_contact_by_email(&conn, "auto@example.com").unwrap();
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.source, "auto");
|
||||
assert_eq!(fetched.name, "Auto User");
|
||||
|
||||
// Auto-adding the same email again should be a no-op (INSERT OR IGNORE).
|
||||
auto_add_contact(&conn, "auto@example.com", "Different Name").unwrap();
|
||||
let fetched2 = get_contact_by_email(&conn, "auto@example.com")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(fetched2.name, "Auto User"); // Name should NOT change.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_increment_contact_count() {
|
||||
let conn = open_test_db();
|
||||
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
||||
|
||||
// Increment twice.
|
||||
increment_contact_count(&conn, "alice@example.com").unwrap();
|
||||
increment_contact_count(&conn, "alice@example.com").unwrap();
|
||||
|
||||
let fetched = get_contact_by_email(&conn, "alice@example.com")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(fetched.contact_count, 2);
|
||||
assert!(fetched.last_contacted.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_known_addresses_basic() {
|
||||
let conn = open_test_db();
|
||||
|
||||
// Populate known_addresses directly (simulating what happens at sync time).
|
||||
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"sender@other.com",
|
||||
"Sender",
|
||||
r#"[{"name":"Bob To","address":"bob@test.com"}]"#,
|
||||
"[]",
|
||||
)
|
||||
.unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"sender@other.com",
|
||||
"Sender",
|
||||
"[]",
|
||||
r#"[{"name":"Charlie Cc","address":"charlie@test.com"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
|
||||
assert_eq!(results[0].email, "alice@test.com");
|
||||
assert_eq!(results[0].name, "Alice From");
|
||||
assert_eq!(results[1].email, "bob@test.com");
|
||||
assert_eq!(results[1].name, "Bob To");
|
||||
assert_eq!(results[2].email, "charlie@test.com");
|
||||
assert_eq!(results[2].name, "Charlie Cc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_known_addresses_excludes_contacts() {
|
||||
let conn = open_test_db();
|
||||
|
||||
// Add alice@test.com as a contact.
|
||||
upsert_contact(
|
||||
&conn,
|
||||
&sample_contact("c1", "alice@test.com", "Alice Contact"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Populate known_addresses (simulating sync).
|
||||
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"bob@test.com",
|
||||
"Bob From",
|
||||
r#"[{"name":"Alice To","address":"alice@test.com"}]"#,
|
||||
"[]",
|
||||
)
|
||||
.unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"charlie@test.com",
|
||||
"Charlie",
|
||||
"[]",
|
||||
r#"[{"name":"Alice Cc","address":"alice@test.com"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Search for "test.com" should exclude alice@test.com (she's a contact).
|
||||
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
||||
|
||||
// Should only have bob and charlie (alice excluded from all sources).
|
||||
assert_eq!(results.len(), 2);
|
||||
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
|
||||
assert!(emails.contains(&"bob@test.com"));
|
||||
assert!(emails.contains(&"charlie@test.com"));
|
||||
assert!(!emails.contains(&"alice@test.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_known_addresses_handles_invalid_json() {
|
||||
let conn = open_test_db();
|
||||
|
||||
// Populate known_addresses (simulating sync).
|
||||
populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"sender@other.com",
|
||||
"Sender",
|
||||
r#"[{"name":"","address":"emptyname@test.com"}]"#,
|
||||
r#"[{"name":"Cc Person","address":"ccperson@test.com"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
populate_known_addresses(
|
||||
&conn,
|
||||
"sender@other.com",
|
||||
"Sender",
|
||||
r#"[{"name":"Json Person","address":"json@test.com"}]"#,
|
||||
"[]",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
|
||||
assert!(emails.contains(&"valid@test.com"));
|
||||
assert!(emails.contains(&"emptyname@test.com"));
|
||||
assert!(emails.contains(&"ccperson@test.com"));
|
||||
assert!(emails.contains(&"json@test.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_populate_known_addresses_deduplicates() {
|
||||
let conn = open_test_db();
|
||||
|
||||
populate_known_addresses(&conn, "alice@test.com", "Alice", "[]", "[]").unwrap();
|
||||
// Same email again with non-empty name -- should update the name.
|
||||
populate_known_addresses(&conn, "alice@test.com", "Alice Different", "[]", "[]").unwrap();
|
||||
|
||||
let results = search_known_addresses(&conn, "alice@test.com", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].name, "Alice Different");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_populate_known_addresses_skips_invalid_json() {
|
||||
let conn = open_test_db();
|
||||
|
||||
// Invalid JSON in to_addresses should not cause an error.
|
||||
populate_known_addresses(&conn, "sender@test.com", "Sender", "not valid json", "[]")
|
||||
.unwrap();
|
||||
|
||||
let results = search_known_addresses(&conn, "sender@test.com", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].email, "sender@test.com");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DisplayPreferences {
|
||||
pub density: String,
|
||||
pub theme: String,
|
||||
pub language: String,
|
||||
pub compose_format: String,
|
||||
pub compose_autosave_ms: i32,
|
||||
pub default_priority: String,
|
||||
pub request_mdn_default: bool,
|
||||
pub attachment_reminder: bool,
|
||||
pub deep_index: bool,
|
||||
pub animation_mode: Option<String>,
|
||||
pub preview_pane: String,
|
||||
pub ui_scale: String,
|
||||
pub color_preset: String,
|
||||
pub accent_color: String,
|
||||
pub background_color: String,
|
||||
pub panel_color: String,
|
||||
pub text_color: String,
|
||||
pub font_family: String,
|
||||
pub font_size: i32,
|
||||
pub row_height: i32,
|
||||
pub folder_pane_width: i32,
|
||||
pub message_pane_width: i32,
|
||||
pub border_radius: i32,
|
||||
pub contrast: String,
|
||||
pub show_avatars: bool,
|
||||
pub show_attachment_icons: bool,
|
||||
pub show_flags: bool,
|
||||
pub reduce_motion: bool,
|
||||
pub date_format: String,
|
||||
pub time_format: String,
|
||||
pub icon_size: i32,
|
||||
pub toolbar_density: String,
|
||||
pub sidebar_item_height: i32,
|
||||
pub reader_max_width: i32,
|
||||
pub composer_width: i32,
|
||||
pub selection_color: String,
|
||||
pub unread_font_weight: i32,
|
||||
pub unread_badge_style: String,
|
||||
pub compact_headers: bool,
|
||||
pub show_borders: bool,
|
||||
pub scrollbar_density: String,
|
||||
pub monochrome: bool,
|
||||
pub folder_background: String,
|
||||
pub message_background: String,
|
||||
pub reader_background: String,
|
||||
pub compose_font_family: String,
|
||||
pub reply_position: String,
|
||||
pub quote_style: String,
|
||||
pub forward_mode: String,
|
||||
pub spellcheck_enabled: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct UpdateDisplayPreferences {
|
||||
pub density: Option<String>,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub compose_format: Option<String>,
|
||||
pub compose_autosave_ms: Option<i32>,
|
||||
pub default_priority: Option<String>,
|
||||
pub request_mdn_default: Option<bool>,
|
||||
pub attachment_reminder: Option<bool>,
|
||||
pub deep_index: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_animation_mode_field")]
|
||||
pub animation_mode: Option<Option<String>>,
|
||||
pub preview_pane: Option<String>,
|
||||
pub ui_scale: Option<String>,
|
||||
pub color_preset: Option<String>,
|
||||
pub accent_color: Option<String>,
|
||||
pub background_color: Option<String>,
|
||||
pub panel_color: Option<String>,
|
||||
pub text_color: Option<String>,
|
||||
pub font_family: Option<String>,
|
||||
pub font_size: Option<i32>,
|
||||
pub row_height: Option<i32>,
|
||||
pub folder_pane_width: Option<i32>,
|
||||
pub message_pane_width: Option<i32>,
|
||||
pub border_radius: Option<i32>,
|
||||
pub contrast: Option<String>,
|
||||
pub show_avatars: Option<bool>,
|
||||
pub show_attachment_icons: Option<bool>,
|
||||
pub show_flags: Option<bool>,
|
||||
pub reduce_motion: Option<bool>,
|
||||
pub date_format: Option<String>,
|
||||
pub time_format: Option<String>,
|
||||
pub icon_size: Option<i32>,
|
||||
pub toolbar_density: Option<String>,
|
||||
pub sidebar_item_height: Option<i32>,
|
||||
pub reader_max_width: Option<i32>,
|
||||
pub composer_width: Option<i32>,
|
||||
pub selection_color: Option<String>,
|
||||
pub unread_font_weight: Option<i32>,
|
||||
pub unread_badge_style: Option<String>,
|
||||
pub compact_headers: Option<bool>,
|
||||
pub show_borders: Option<bool>,
|
||||
pub scrollbar_density: Option<String>,
|
||||
pub monochrome: Option<bool>,
|
||||
pub folder_background: Option<String>,
|
||||
pub message_background: Option<String>,
|
||||
pub reader_background: Option<String>,
|
||||
pub compose_font_family: Option<String>,
|
||||
pub reply_position: Option<String>,
|
||||
pub quote_style: Option<String>,
|
||||
pub forward_mode: Option<String>,
|
||||
pub spellcheck_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
fn deserialize_animation_mode_field<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn defaults() -> DisplayPreferences {
|
||||
DisplayPreferences {
|
||||
density: "compact".into(),
|
||||
theme: "system".into(),
|
||||
language: "en".into(),
|
||||
compose_format: "html".into(),
|
||||
compose_autosave_ms: 3000,
|
||||
default_priority: "normal".into(),
|
||||
request_mdn_default: false,
|
||||
attachment_reminder: true,
|
||||
deep_index: false,
|
||||
animation_mode: None,
|
||||
preview_pane: "right".into(),
|
||||
ui_scale: "normal".into(),
|
||||
color_preset: "sage".into(),
|
||||
accent_color: "#6f9c86".into(),
|
||||
background_color: "#121513".into(),
|
||||
panel_color: "#181c19".into(),
|
||||
text_color: "#edf0ec".into(),
|
||||
font_family: "system".into(),
|
||||
font_size: 13,
|
||||
row_height: 58,
|
||||
folder_pane_width: 228,
|
||||
message_pane_width: 420,
|
||||
border_radius: 4,
|
||||
contrast: "normal".into(),
|
||||
show_avatars: true,
|
||||
show_attachment_icons: true,
|
||||
show_flags: true,
|
||||
reduce_motion: false,
|
||||
date_format: "locale".into(),
|
||||
time_format: "locale".into(),
|
||||
icon_size: 18,
|
||||
toolbar_density: "normal".into(),
|
||||
sidebar_item_height: 29,
|
||||
reader_max_width: 0,
|
||||
composer_width: 900,
|
||||
selection_color: "#28352e".into(),
|
||||
unread_font_weight: 730,
|
||||
unread_badge_style: "dot".into(),
|
||||
compact_headers: false,
|
||||
show_borders: true,
|
||||
scrollbar_density: "thin".into(),
|
||||
monochrome: false,
|
||||
folder_background: String::new(),
|
||||
message_background: String::new(),
|
||||
reader_background: String::new(),
|
||||
compose_font_family: "system".into(),
|
||||
reply_position: "above".into(),
|
||||
quote_style: "standard".into(),
|
||||
forward_mode: "inline".into(),
|
||||
spellcheck_enabled: true,
|
||||
updated_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_preferences(conn: &Connection) -> Result<DisplayPreferences, String> {
|
||||
let result = conn.query_row(
|
||||
"SELECT density, theme, language, compose_format, deep_index, animation_mode, preview_pane, ui_scale, color_preset, accent_color, background_color, panel_color, text_color, font_family, font_size, row_height, folder_pane_width, message_pane_width, border_radius, contrast, show_avatars, show_attachment_icons, show_flags, reduce_motion, date_format, time_format, compose_autosave_ms, default_priority, request_mdn_default, attachment_reminder, icon_size, toolbar_density, sidebar_item_height, reader_max_width, composer_width, selection_color, unread_font_weight, unread_badge_style, compact_headers, show_borders, scrollbar_density, monochrome, folder_background, message_background, reader_background, compose_font_family, reply_position, quote_style, forward_mode, spellcheck_enabled, updated_at FROM display_preferences WHERE id = 1",
|
||||
[],
|
||||
|row| Ok(DisplayPreferences {
|
||||
density: row.get(0)?, theme: row.get(1)?, language: row.get(2)?, compose_format: row.get(3)?,
|
||||
deep_index: row.get::<_, i32>(4)? != 0, animation_mode: row.get(5)?, preview_pane: row.get(6)?,
|
||||
ui_scale: row.get(7)?, color_preset: row.get(8)?, accent_color: row.get(9)?, background_color: row.get(10)?, panel_color: row.get(11)?, text_color: row.get(12)?, font_family: row.get(13)?,
|
||||
font_size: row.get(14)?, row_height: row.get(15)?, folder_pane_width: row.get(16)?, message_pane_width: row.get(17)?,
|
||||
border_radius: row.get(18)?, contrast: row.get(19)?, show_avatars: row.get::<_, i32>(20)? != 0,
|
||||
show_attachment_icons: row.get::<_, i32>(21)? != 0, show_flags: row.get::<_, i32>(22)? != 0,
|
||||
reduce_motion: row.get::<_, i32>(23)? != 0, date_format: row.get(24)?, time_format: row.get(25)?,
|
||||
compose_autosave_ms: row.get(26)?, default_priority: row.get(27)?, request_mdn_default: row.get::<_, i32>(28)? != 0, attachment_reminder: row.get::<_, i32>(29)? != 0,
|
||||
icon_size: row.get(30)?, toolbar_density: row.get(31)?, sidebar_item_height: row.get(32)?, reader_max_width: row.get(33)?, composer_width: row.get(34)?,
|
||||
selection_color: row.get(35)?, unread_font_weight: row.get(36)?, unread_badge_style: row.get(37)?, compact_headers: row.get::<_, i32>(38)? != 0, show_borders: row.get::<_, i32>(39)? != 0,
|
||||
scrollbar_density: row.get(40)?, monochrome: row.get::<_, i32>(41)? != 0, folder_background: row.get(42)?, message_background: row.get(43)?, reader_background: row.get(44)?, compose_font_family: row.get(45)?, reply_position: row.get(46)?, quote_style: row.get(47)?, forward_mode: row.get(48)?, spellcheck_enabled: row.get::<_, i32>(49)? != 0, updated_at: row.get(50)?,
|
||||
}),
|
||||
);
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(defaults()),
|
||||
Err(error) => Err(format!("Failed to get display preferences: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn enum_value(name: &str, value: &str, allowed: &[&str]) -> Result<(), String> {
|
||||
if allowed.contains(&value) { Ok(()) } else { Err(format!("Invalid {name}: {value}")) }
|
||||
}
|
||||
fn range(name: &str, value: i32, min: i32, max: i32) -> Result<(), String> {
|
||||
if (min..=max).contains(&value) { Ok(()) } else { Err(format!("Invalid {name}: {value}")) }
|
||||
}
|
||||
fn valid_hex(value: &str) -> bool {
|
||||
value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
pub fn update_preferences(conn: &Connection, data: &UpdateDisplayPreferences) -> Result<DisplayPreferences, String> {
|
||||
conn.execute("INSERT OR IGNORE INTO display_preferences (id) VALUES (1)", [])
|
||||
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
|
||||
|
||||
let mut sets = Vec::new();
|
||||
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let mut idx = 1usize;
|
||||
macro_rules! set_text { ($field:ident, $column:literal) => { if let Some(ref value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); values.push(Box::new(value.clone())); idx += 1; } }; }
|
||||
macro_rules! set_int { ($field:ident, $column:literal) => { if let Some(value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); values.push(Box::new(value)); idx += 1; } }; }
|
||||
macro_rules! set_bool { ($field:ident, $column:literal) => { if let Some(value) = data.$field { sets.push(format!(concat!($column, " = ?{}"), idx)); values.push(Box::new(value as i32)); idx += 1; } }; }
|
||||
|
||||
if let Some(ref v) = data.density { enum_value("density", v, &["compact", "comfortable"])?; }
|
||||
if let Some(ref v) = data.theme { enum_value("theme", v, &["light", "dark", "system"])?; }
|
||||
if let Some(ref v) = data.compose_format { enum_value("compose_format", v, &["html", "text", "markdown"])?; }
|
||||
if let Some(v) = data.compose_autosave_ms { range("compose_autosave_ms", v, 1000, 60000)?; }
|
||||
if let Some(ref v) = data.default_priority { enum_value("default_priority", v, &["high", "normal", "low"])?; }
|
||||
if let Some(ref v) = data.preview_pane { enum_value("preview_pane", v, &["right", "bottom", "off"])?; }
|
||||
if let Some(ref v) = data.ui_scale { enum_value("ui_scale", v, &["small", "normal", "large"])?; }
|
||||
if let Some(ref v) = data.color_preset { enum_value("color_preset", v, &["sage", "blue", "teal", "cyan", "indigo", "slate", "graphite", "amber", "orange", "violet", "rose", "red", "custom"])?; }
|
||||
if let Some(ref v) = data.font_family { enum_value("font_family", v, &["system", "inter", "humanist", "mono"])?; }
|
||||
if let Some(ref v) = data.contrast { enum_value("contrast", v, &["soft", "normal", "high"])?; }
|
||||
if let Some(ref v) = data.language { enum_value("language", v, &["en", "pl"])?; }
|
||||
if let Some(ref v) = data.date_format { enum_value("date_format", v, &["locale", "iso", "eu", "us"])?; }
|
||||
if let Some(ref v) = data.time_format { enum_value("time_format", v, &["locale", "24h", "12h"])?; }
|
||||
if let Some(ref v) = data.toolbar_density { enum_value("toolbar_density", v, &["compact", "normal", "spacious"])?; }
|
||||
if let Some(ref v) = data.unread_badge_style { enum_value("unread_badge_style", v, &["dot", "outline", "solid"])?; }
|
||||
if let Some(ref v) = data.scrollbar_density { enum_value("scrollbar_density", v, &["hidden", "thin", "normal"])?; }
|
||||
if let Some(ref v) = data.compose_font_family { enum_value("compose_font_family", v, &["system", "humanist", "serif", "mono"])?; }
|
||||
if let Some(ref v) = data.reply_position { enum_value("reply_position", v, &["above", "below"])?; }
|
||||
if let Some(ref v) = data.quote_style { enum_value("quote_style", v, &["standard", "compact", "none"])?; }
|
||||
if let Some(ref v) = data.forward_mode { enum_value("forward_mode", v, &["inline", "attachment"])?; }
|
||||
if let Some(v) = data.font_size { range("font_size", v, 11, 18)?; }
|
||||
if let Some(v) = data.row_height { range("row_height", v, 42, 88)?; }
|
||||
if let Some(v) = data.folder_pane_width { range("folder_pane_width", v, 180, 360)?; }
|
||||
if let Some(v) = data.message_pane_width { range("message_pane_width", v, 300, 720)?; }
|
||||
if let Some(v) = data.border_radius { range("border_radius", v, 0, 12)?; }
|
||||
if let Some(v) = data.icon_size { range("icon_size", v, 14, 28)?; }
|
||||
if let Some(v) = data.sidebar_item_height { range("sidebar_item_height", v, 24, 48)?; }
|
||||
if let Some(v) = data.reader_max_width { if v != 0 { range("reader_max_width", v, 560, 1400)?; } }
|
||||
if let Some(v) = data.composer_width { range("composer_width", v, 640, 1400)?; }
|
||||
if let Some(v) = data.unread_font_weight { range("unread_font_weight", v, 500, 800)?; }
|
||||
if let Some(ref v) = data.accent_color { if !valid_hex(v) { return Err(format!("Invalid accent_color: {v}")); } }
|
||||
if let Some(ref v) = data.background_color { if !valid_hex(v) { return Err(format!("Invalid background_color: {v}")); } }
|
||||
if let Some(ref v) = data.panel_color { if !valid_hex(v) { return Err(format!("Invalid panel_color: {v}")); } }
|
||||
if let Some(ref v) = data.text_color { if !valid_hex(v) { return Err(format!("Invalid text_color: {v}")); } }
|
||||
if let Some(ref v) = data.selection_color { if !valid_hex(v) { return Err(format!("Invalid selection_color: {v}")); } }
|
||||
for (name, value) in [("folder_background", data.folder_background.as_deref()), ("message_background", data.message_background.as_deref()), ("reader_background", data.reader_background.as_deref())] {
|
||||
if let Some(v) = value { if !v.is_empty() && !valid_hex(v) { return Err(format!("Invalid {name}: {v}")); } }
|
||||
}
|
||||
if let Some(ref mode) = data.animation_mode { if let Some(v) = mode { enum_value("animation_mode", v, &["rich", "medium", "subtle", "off"])?; } }
|
||||
|
||||
set_text!(density, "density"); set_text!(theme, "theme"); set_text!(language, "language"); set_text!(compose_format, "compose_format");
|
||||
set_int!(compose_autosave_ms, "compose_autosave_ms"); set_text!(default_priority, "default_priority"); set_bool!(request_mdn_default, "request_mdn_default"); set_bool!(attachment_reminder, "attachment_reminder");
|
||||
set_bool!(deep_index, "deep_index");
|
||||
if let Some(ref value) = data.animation_mode { sets.push(format!("animation_mode = ?{}", idx)); values.push(Box::new(value.clone())); idx += 1; }
|
||||
set_text!(preview_pane, "preview_pane"); set_text!(ui_scale, "ui_scale"); set_text!(color_preset, "color_preset"); set_text!(accent_color, "accent_color");
|
||||
set_text!(background_color, "background_color"); set_text!(panel_color, "panel_color"); set_text!(text_color, "text_color");
|
||||
set_text!(font_family, "font_family"); set_int!(font_size, "font_size"); set_int!(row_height, "row_height"); set_int!(folder_pane_width, "folder_pane_width");
|
||||
set_int!(message_pane_width, "message_pane_width"); set_int!(border_radius, "border_radius"); set_text!(contrast, "contrast");
|
||||
set_bool!(show_avatars, "show_avatars"); set_bool!(show_attachment_icons, "show_attachment_icons"); set_bool!(show_flags, "show_flags"); set_bool!(reduce_motion, "reduce_motion");
|
||||
set_text!(date_format, "date_format"); set_text!(time_format, "time_format");
|
||||
set_int!(icon_size, "icon_size"); set_text!(toolbar_density, "toolbar_density"); set_int!(sidebar_item_height, "sidebar_item_height");
|
||||
set_int!(reader_max_width, "reader_max_width"); set_int!(composer_width, "composer_width"); set_text!(selection_color, "selection_color");
|
||||
set_int!(unread_font_weight, "unread_font_weight"); set_text!(unread_badge_style, "unread_badge_style"); set_bool!(compact_headers, "compact_headers");
|
||||
set_bool!(show_borders, "show_borders"); set_text!(scrollbar_density, "scrollbar_density"); set_bool!(monochrome, "monochrome");
|
||||
set_text!(folder_background, "folder_background"); set_text!(message_background, "message_background"); set_text!(reader_background, "reader_background");
|
||||
set_text!(compose_font_family, "compose_font_family"); set_text!(reply_position, "reply_position"); set_text!(quote_style, "quote_style"); set_text!(forward_mode, "forward_mode"); set_bool!(spellcheck_enabled, "spellcheck_enabled");
|
||||
|
||||
if sets.is_empty() { return get_preferences(conn); }
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
let sql = format!("UPDATE display_preferences SET {} WHERE id = ?{}", sets.join(", "), idx);
|
||||
values.push(Box::new(1_i32));
|
||||
let refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|v| v.as_ref()).collect();
|
||||
conn.execute(&sql, refs.as_slice()).map_err(|e| format!("Failed to update display preferences: {e}"))?;
|
||||
get_preferences(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn defaults_and_personalization_roundtrip() {
|
||||
let conn = open_test_db();
|
||||
let defaults = get_preferences(&conn).unwrap();
|
||||
assert_eq!(defaults.color_preset, "sage");
|
||||
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({
|
||||
"theme":"dark", "language":"pl", "color_preset":"blue", "accent_color":"#336699",
|
||||
"font_size":15, "row_height":64, "folder_pane_width":250, "message_pane_width":480,
|
||||
"show_avatars":false, "reduce_motion":true
|
||||
})).unwrap();
|
||||
let prefs = update_preferences(&conn, &update).unwrap();
|
||||
assert_eq!(prefs.language, "pl");
|
||||
assert_eq!(prefs.accent_color, "#336699");
|
||||
assert_eq!(prefs.font_size, 15);
|
||||
assert!(!prefs.show_avatars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_customization_is_rejected() {
|
||||
let conn = open_test_db();
|
||||
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({"accent_color":"red"})).unwrap();
|
||||
assert!(update_preferences(&conn, &update).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Serialize;
|
||||
|
||||
/// A locally-saved draft message.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Draft {
|
||||
pub id: String,
|
||||
pub to_addresses: String,
|
||||
pub cc_addresses: String,
|
||||
pub bcc_addresses: String,
|
||||
pub subject: String,
|
||||
pub text_body: String,
|
||||
pub html_body: Option<String>,
|
||||
pub in_reply_to: Option<String>,
|
||||
pub references_header: Option<String>,
|
||||
pub request_mdn: bool,
|
||||
pub priority: String,
|
||||
pub compose_format: String,
|
||||
pub pgp_mode: String,
|
||||
pub pgp_inline: bool,
|
||||
pub pgp_attach_public_key: bool,
|
||||
pub pgp_attach_recipient_keys: bool,
|
||||
pub remote_folder: Option<String>,
|
||||
pub remote_uid: Option<u32>,
|
||||
pub remote_uid_validity: Option<u32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// An attachment associated with a draft.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DraftAttachment {
|
||||
pub id: String,
|
||||
pub draft_id: String,
|
||||
pub filename: String,
|
||||
pub content_type: String,
|
||||
pub size: i64,
|
||||
pub file_path: 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> {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
return Err("Draft ID must be between 1 and 128 characters".to_string());
|
||||
}
|
||||
if !id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return Err("Draft ID contains unsupported characters".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert or update a draft. Uses INSERT OR REPLACE to upsert.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn upsert_draft(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
to_addresses: &str,
|
||||
cc_addresses: &str,
|
||||
bcc_addresses: &str,
|
||||
subject: &str,
|
||||
text_body: &str,
|
||||
html_body: Option<&str>,
|
||||
in_reply_to: Option<&str>,
|
||||
references_header: Option<&str>,
|
||||
request_mdn: bool,
|
||||
priority: &str,
|
||||
compose_format: &str,
|
||||
pgp_mode: &str,
|
||||
pgp_inline: bool,
|
||||
pgp_attach_public_key: bool,
|
||||
pgp_attach_recipient_keys: bool,
|
||||
) -> Result<(), String> {
|
||||
validate_draft_id(id)?;
|
||||
conn.execute(
|
||||
"INSERT INTO drafts (id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
to_addresses = excluded.to_addresses,
|
||||
cc_addresses = excluded.cc_addresses,
|
||||
bcc_addresses = excluded.bcc_addresses,
|
||||
subject = excluded.subject,
|
||||
text_body = excluded.text_body,
|
||||
html_body = excluded.html_body,
|
||||
in_reply_to = excluded.in_reply_to,
|
||||
references_header = excluded.references_header,
|
||||
request_mdn = excluded.request_mdn,
|
||||
priority = excluded.priority,
|
||||
compose_format = excluded.compose_format,
|
||||
pgp_mode = excluded.pgp_mode,
|
||||
pgp_inline = excluded.pgp_inline,
|
||||
pgp_attach_public_key = excluded.pgp_attach_public_key,
|
||||
pgp_attach_recipient_keys = excluded.pgp_attach_recipient_keys,
|
||||
updated_at = datetime('now')",
|
||||
params![
|
||||
id,
|
||||
to_addresses,
|
||||
cc_addresses,
|
||||
bcc_addresses,
|
||||
subject,
|
||||
text_body,
|
||||
html_body,
|
||||
in_reply_to,
|
||||
references_header,
|
||||
if request_mdn { 1 } else { 0 },
|
||||
priority,
|
||||
compose_format,
|
||||
pgp_mode,
|
||||
if pgp_inline { 1 } else { 0 },
|
||||
if pgp_attach_public_key { 1 } else { 0 },
|
||||
if pgp_attach_recipient_keys { 1 } else { 0 },
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to upsert draft: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a single draft by ID.
|
||||
pub fn get_draft(conn: &Connection, id: &str) -> Result<Option<Draft>, String> {
|
||||
let result = conn.query_row(
|
||||
"SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
|
||||
FROM drafts WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(Draft {
|
||||
id: row.get(0)?,
|
||||
to_addresses: row.get(1)?,
|
||||
cc_addresses: row.get(2)?,
|
||||
bcc_addresses: row.get(3)?,
|
||||
subject: row.get(4)?,
|
||||
text_body: row.get(5)?,
|
||||
html_body: row.get(6)?,
|
||||
in_reply_to: row.get(7)?,
|
||||
references_header: row.get(8)?,
|
||||
request_mdn: row.get::<_, i64>(9)? != 0,
|
||||
priority: row.get(10)?,
|
||||
compose_format: row.get(11)?,
|
||||
pgp_mode: row.get(12)?,
|
||||
pgp_inline: row.get::<_, i64>(13)? != 0,
|
||||
pgp_attach_public_key: row.get::<_, i64>(14)? != 0,
|
||||
pgp_attach_recipient_keys: row.get::<_, i64>(15)? != 0,
|
||||
remote_folder: row.get(16)?,
|
||||
remote_uid: row.get(17)?,
|
||||
remote_uid_validity: row.get(18)?,
|
||||
created_at: row.get(19)?,
|
||||
updated_at: row.get(20)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(draft) => Ok(Some(draft)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get draft: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all drafts, ordered by updated_at descending.
|
||||
pub fn list_drafts(conn: &Connection) -> Result<Vec<Draft>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, in_reply_to, references_header, request_mdn, priority, compose_format, pgp_mode, pgp_inline, pgp_attach_public_key, pgp_attach_recipient_keys, remote_folder, remote_uid, remote_uid_validity, created_at, updated_at
|
||||
FROM drafts ORDER BY updated_at DESC",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare list_drafts: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Draft {
|
||||
id: row.get(0)?,
|
||||
to_addresses: row.get(1)?,
|
||||
cc_addresses: row.get(2)?,
|
||||
bcc_addresses: row.get(3)?,
|
||||
subject: row.get(4)?,
|
||||
text_body: row.get(5)?,
|
||||
html_body: row.get(6)?,
|
||||
in_reply_to: row.get(7)?,
|
||||
references_header: row.get(8)?,
|
||||
request_mdn: row.get::<_, i64>(9)? != 0,
|
||||
priority: row.get(10)?,
|
||||
compose_format: row.get(11)?,
|
||||
pgp_mode: row.get(12)?,
|
||||
pgp_inline: row.get::<_, i64>(13)? != 0,
|
||||
pgp_attach_public_key: row.get::<_, i64>(14)? != 0,
|
||||
pgp_attach_recipient_keys: row.get::<_, i64>(15)? != 0,
|
||||
remote_folder: row.get(16)?,
|
||||
remote_uid: row.get(17)?,
|
||||
remote_uid_validity: row.get(18)?,
|
||||
created_at: row.get(19)?,
|
||||
updated_at: row.get(20)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query drafts: {e}"))?;
|
||||
|
||||
let mut drafts = Vec::new();
|
||||
for row in rows {
|
||||
drafts.push(row.map_err(|e| format!("Failed to read draft row: {e}"))?);
|
||||
}
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
|
||||
/// Update the IMAP location of the latest remote snapshot for a local draft.
|
||||
pub fn set_remote_location(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
folder: &str,
|
||||
uid: u32,
|
||||
uid_validity: u32,
|
||||
) -> Result<(), String> {
|
||||
let updated = conn
|
||||
.execute(
|
||||
"UPDATE drafts SET remote_folder = ?2, remote_uid = ?3, remote_uid_validity = ?4 WHERE id = ?1",
|
||||
params![id, folder, uid, uid_validity],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update remote draft location: {e}"))?;
|
||||
if updated == 0 {
|
||||
return Err("Draft not found while updating remote location".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a draft by ID. Cascade will handle attachments in DB.
|
||||
pub fn delete_draft(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM drafts WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete draft: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
/// Add an attachment record for a draft.
|
||||
pub fn add_draft_attachment(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
draft_id: &str,
|
||||
filename: &str,
|
||||
content_type: &str,
|
||||
size: i64,
|
||||
file_path: &str,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT INTO draft_attachments (id, draft_id, filename, content_type, size, file_path)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![id, draft_id, filename, content_type, size, file_path],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add draft attachment: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all attachments for a draft.
|
||||
pub fn get_draft_attachments(
|
||||
conn: &Connection,
|
||||
draft_id: &str,
|
||||
) -> Result<Vec<DraftAttachment>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, draft_id, filename, content_type, size, file_path, created_at
|
||||
FROM draft_attachments WHERE draft_id = ?1 ORDER BY created_at ASC",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare get_draft_attachments: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![draft_id], |row| {
|
||||
Ok(DraftAttachment {
|
||||
id: row.get(0)?,
|
||||
draft_id: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
content_type: row.get(3)?,
|
||||
size: row.get(4)?,
|
||||
file_path: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query draft attachments: {e}"))?;
|
||||
|
||||
let mut attachments = Vec::new();
|
||||
for row in rows {
|
||||
attachments.push(row.map_err(|e| format!("Failed to read attachment row: {e}"))?);
|
||||
}
|
||||
Ok(attachments)
|
||||
}
|
||||
|
||||
/// Delete a single attachment by ID.
|
||||
pub fn delete_draft_attachment(conn: &Connection, id: &str) -> Result<bool, String> {
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM draft_attachments WHERE id = ?1", params![id])
|
||||
.map_err(|e| format!("Failed to delete draft attachment: {e}"))?;
|
||||
Ok(deleted > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn test_upsert_and_get_draft() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(
|
||||
&conn,
|
||||
"draft-1",
|
||||
"bob@example.com",
|
||||
"",
|
||||
"",
|
||||
"Hello",
|
||||
"Hi Bob",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
"high",
|
||||
"html",
|
||||
"none",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
||||
assert_eq!(draft.id, "draft-1");
|
||||
assert_eq!(draft.to_addresses, "bob@example.com");
|
||||
assert_eq!(draft.subject, "Hello");
|
||||
assert_eq!(draft.text_body, "Hi Bob");
|
||||
assert!(draft.html_body.is_none());
|
||||
assert!(draft.request_mdn);
|
||||
assert_eq!(draft.priority, "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_updates_existing() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "bob@example.com", "", "", "v1", "body1", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
upsert_draft(&conn, "draft-1", "carol@example.com", "", "", "v2", "body2", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
|
||||
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
||||
assert_eq!(draft.to_addresses, "carol@example.com");
|
||||
assert_eq!(draft.subject, "v2");
|
||||
assert_eq!(draft.text_body, "body2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_drafts_returns_all() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-a", "", "", "", "First", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
upsert_draft(&conn, "draft-b", "", "", "", "Second", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
|
||||
let drafts = list_drafts(&conn).unwrap();
|
||||
assert_eq!(drafts.len(), 2);
|
||||
let ids: Vec<&str> = drafts.iter().map(|d| d.id.as_str()).collect();
|
||||
assert!(ids.contains(&"draft-a"));
|
||||
assert!(ids.contains(&"draft-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_draft() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
assert!(get_draft(&conn, "draft-1").unwrap().is_some());
|
||||
|
||||
let deleted = delete_draft(&conn, "draft-1").unwrap();
|
||||
assert!(deleted);
|
||||
assert!(get_draft(&conn, "draft-1").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_location_survives_local_draft_update() {
|
||||
let conn = open_test_db();
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "v1", "body", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
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();
|
||||
let draft = get_draft(&conn, "draft-1").unwrap().unwrap();
|
||||
assert_eq!(draft.remote_folder.as_deref(), Some("Drafts"));
|
||||
assert_eq!(draft.remote_uid, Some(42));
|
||||
assert_eq!(draft.remote_uid_validity, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_id_validation_rejects_path_traversal() {
|
||||
assert!(validate_draft_id("normal-id_123").is_ok());
|
||||
assert!(validate_draft_id("../escape").is_err());
|
||||
assert!(validate_draft_id("folder/draft").is_err());
|
||||
assert!(validate_draft_id("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_draft() {
|
||||
let conn = open_test_db();
|
||||
let deleted = delete_draft(&conn, "no-such-draft").unwrap();
|
||||
assert!(!deleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_nonexistent_draft() {
|
||||
let conn = open_test_db();
|
||||
assert!(get_draft(&conn, "no-such-draft").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_get_attachments() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
"att-1",
|
||||
"draft-1",
|
||||
"file.pdf",
|
||||
"application/pdf",
|
||||
1024,
|
||||
"/data/abc/attachments/draft-1/att-1",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_draft_attachment(
|
||||
&conn,
|
||||
"att-2",
|
||||
"draft-1",
|
||||
"photo.jpg",
|
||||
"image/jpeg",
|
||||
2048,
|
||||
"/data/abc/attachments/draft-1/att-2",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let atts = get_draft_attachments(&conn, "draft-1").unwrap();
|
||||
assert_eq!(atts.len(), 2);
|
||||
assert_eq!(atts[0].filename, "file.pdf");
|
||||
assert_eq!(atts[0].size, 1024);
|
||||
assert_eq!(atts[1].filename, "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_attachment() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
add_draft_attachment(&conn, "att-1", "draft-1", "file.pdf", "application/pdf", 100, "/path").unwrap();
|
||||
|
||||
let deleted = delete_draft_attachment(&conn, "att-1").unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
let atts = get_draft_attachments(&conn, "draft-1").unwrap();
|
||||
assert!(atts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cascade_delete_attachments_on_draft_delete() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_draft(&conn, "draft-1", "", "", "", "Test", "", None, None, None, false, "normal", "html", "none", false, false, false).unwrap();
|
||||
add_draft_attachment(&conn, "att-1", "draft-1", "file.pdf", "application/pdf", 100, "/path").unwrap();
|
||||
add_draft_attachment(&conn, "att-2", "draft-1", "pic.jpg", "image/jpeg", 200, "/path2").unwrap();
|
||||
|
||||
delete_draft(&conn, "draft-1").unwrap();
|
||||
|
||||
let atts = get_draft_attachments(&conn, "draft-1").unwrap();
|
||||
assert!(atts.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FolderPreferences {
|
||||
pub flagged_on_top: Option<bool>,
|
||||
pub effective_flagged_on_top: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateFolderPreferences {
|
||||
pub flagged_on_top: Option<bool>,
|
||||
}
|
||||
|
||||
fn get_override(conn: &Connection, folder: &str) -> Result<Option<bool>, String> {
|
||||
conn.query_row(
|
||||
"SELECT flagged_on_top FROM folder_preferences WHERE folder_name = ?1",
|
||||
[folder],
|
||||
|row| row.get::<_, Option<i32>>(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|value| value.flatten().map(|value| value != 0))
|
||||
.map_err(|error| format!("Failed to read folder preferences: {error}"))
|
||||
}
|
||||
|
||||
pub fn effective_flagged_on_top(conn: &Connection, folder: &str) -> Result<bool, String> {
|
||||
if let Some(value) = get_override(conn, folder)? {
|
||||
return Ok(value);
|
||||
}
|
||||
let global = conn
|
||||
.query_row(
|
||||
"SELECT flagged_on_top FROM mail_preferences WHERE id = 1",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok(global != 0)
|
||||
}
|
||||
|
||||
pub fn get_preferences(conn: &Connection, folder: &str) -> Result<FolderPreferences, String> {
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
|
||||
[folder],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("Failed to validate folder: {error}"))?;
|
||||
if !exists {
|
||||
return Err(format!("Folder does not exist: {folder}"));
|
||||
}
|
||||
let flagged_on_top = get_override(conn, folder)?;
|
||||
let effective_flagged_on_top = flagged_on_top.unwrap_or(
|
||||
conn.query_row(
|
||||
"SELECT flagged_on_top FROM mail_preferences WHERE id = 1",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
!= 0,
|
||||
);
|
||||
Ok(FolderPreferences {
|
||||
flagged_on_top,
|
||||
effective_flagged_on_top,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_preferences(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
data: &UpdateFolderPreferences,
|
||||
) -> Result<FolderPreferences, String> {
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM folders WHERE name = ?1)",
|
||||
[folder],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("Failed to validate folder: {error}"))?;
|
||||
if !exists {
|
||||
return Err(format!("Folder does not exist: {folder}"));
|
||||
}
|
||||
|
||||
match data.flagged_on_top {
|
||||
Some(value) => {
|
||||
conn.execute(
|
||||
"INSERT INTO folder_preferences(folder_name, flagged_on_top, updated_at) VALUES(?1, ?2, datetime('now'))\n ON CONFLICT(folder_name) DO UPDATE SET flagged_on_top = excluded.flagged_on_top, updated_at = datetime('now')",
|
||||
params![folder, value as i32],
|
||||
)
|
||||
.map_err(|error| format!("Failed to update folder preferences: {error}"))?;
|
||||
}
|
||||
None => {
|
||||
conn.execute("DELETE FROM folder_preferences WHERE folder_name = ?1", [folder])
|
||||
.map_err(|error| format!("Failed to clear folder preferences: {error}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
get_preferences(conn, folder)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::{folders, mail_preferences};
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn folder_override_inherits_and_overrides_global_value() {
|
||||
let conn = open_test_db();
|
||||
folders::insert_folder_if_new(&conn, "INBOX", Some("/"), "").unwrap();
|
||||
assert!(!get_preferences(&conn, "INBOX").unwrap().effective_flagged_on_top);
|
||||
|
||||
mail_preferences::update_preferences(
|
||||
&conn,
|
||||
&mail_preferences::UpdateMailPreferences {
|
||||
messages_per_page: None,
|
||||
mark_read_delay_ms: None,
|
||||
remote_images: None,
|
||||
preferred_body: None,
|
||||
thread_mode: None,
|
||||
collapse_quotes: None,
|
||||
show_snippets: None,
|
||||
default_sort: None,
|
||||
flagged_on_top: Some(true),
|
||||
sync_imap_keywords: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(get_preferences(&conn, "INBOX").unwrap().effective_flagged_on_top);
|
||||
|
||||
let overridden = update_preferences(
|
||||
&conn,
|
||||
"INBOX",
|
||||
&UpdateFolderPreferences { flagged_on_top: Some(false) },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(overridden.flagged_on_top, Some(false));
|
||||
assert!(!overridden.effective_flagged_on_top);
|
||||
|
||||
let inherited = update_preferences(
|
||||
&conn,
|
||||
"INBOX",
|
||||
&UpdateFolderPreferences { flagged_on_top: None },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(inherited.flagged_on_top, None);
|
||||
assert!(inherited.effective_flagged_on_top);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::Serialize;
|
||||
|
||||
/// A cached IMAP folder, mirroring the `folders` table schema.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CachedFolder {
|
||||
pub name: String,
|
||||
pub delimiter: Option<String>,
|
||||
pub parent: Option<String>,
|
||||
pub flags: String,
|
||||
pub is_subscribed: bool,
|
||||
pub total_count: u32,
|
||||
pub unread_count: u32,
|
||||
pub uid_validity: u32,
|
||||
pub highest_modseq: u64,
|
||||
}
|
||||
|
||||
/// Insert or replace a folder row in the `folders` table.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn upsert_folder(
|
||||
conn: &Connection,
|
||||
folder_name: &str,
|
||||
delimiter: Option<&str>,
|
||||
parent: Option<&str>,
|
||||
flags_csv: &str,
|
||||
is_subscribed: bool,
|
||||
total_count: u32,
|
||||
unread_count: u32,
|
||||
uid_validity: u32,
|
||||
highest_modseq: u64,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO folders
|
||||
(name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))",
|
||||
params![
|
||||
folder_name,
|
||||
delimiter,
|
||||
parent,
|
||||
flags_csv,
|
||||
is_subscribed as i32,
|
||||
total_count,
|
||||
unread_count,
|
||||
uid_validity,
|
||||
highest_modseq as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to upsert folder: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a folder only if it doesn't already exist.
|
||||
///
|
||||
/// Uses `INSERT OR IGNORE` to avoid triggering `ON DELETE CASCADE` on the
|
||||
/// messages table (which `INSERT OR REPLACE` would do since it is internally
|
||||
/// a DELETE + INSERT).
|
||||
pub fn insert_folder_if_new(
|
||||
conn: &Connection,
|
||||
folder_name: &str,
|
||||
delimiter: Option<&str>,
|
||||
flags_csv: &str,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folders
|
||||
(name, delimiter, flags, is_subscribed, updated_at)
|
||||
VALUES (?1, ?2, ?3, 1, datetime('now'))",
|
||||
params![folder_name, delimiter, flags_csv],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert folder: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return all cached folders, sorted alphabetically by name.
|
||||
pub fn get_all_folders(conn: &Connection) -> Result<Vec<CachedFolder>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq
|
||||
FROM folders
|
||||
ORDER BY name",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare get_all_folders: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let is_subscribed_int: i32 = row.get(4)?;
|
||||
let highest_modseq_int: i64 = row.get(8)?;
|
||||
Ok(CachedFolder {
|
||||
name: row.get(0)?,
|
||||
delimiter: row.get(1)?,
|
||||
parent: row.get(2)?,
|
||||
flags: row.get(3)?,
|
||||
is_subscribed: is_subscribed_int != 0,
|
||||
total_count: row.get(5)?,
|
||||
unread_count: row.get(6)?,
|
||||
uid_validity: row.get(7)?,
|
||||
highest_modseq: highest_modseq_int as u64,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query folders: {e}"))?;
|
||||
|
||||
let mut folders = Vec::new();
|
||||
for row in rows {
|
||||
folders.push(row.map_err(|e| format!("Failed to read folder row: {e}"))?);
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
/// Delete folders whose names are NOT in the provided `current_names` list.
|
||||
/// Returns the number of deleted rows.
|
||||
pub fn remove_stale_folders(
|
||||
conn: &Connection,
|
||||
current_names: &[String],
|
||||
) -> Result<usize, String> {
|
||||
if current_names.is_empty() {
|
||||
// Delete all folders when the current list is empty.
|
||||
let deleted = conn
|
||||
.execute("DELETE FROM folders", [])
|
||||
.map_err(|e| format!("Failed to delete all folders: {e}"))?;
|
||||
return Ok(deleted);
|
||||
}
|
||||
|
||||
// Build a parameterized WHERE NOT IN clause.
|
||||
let placeholders: Vec<String> = (1..=current_names.len())
|
||||
.map(|i| format!("?{i}"))
|
||||
.collect();
|
||||
let sql = format!(
|
||||
"DELETE FROM folders WHERE name NOT IN ({})",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
|
||||
let params: Vec<&dyn rusqlite::types::ToSql> = current_names
|
||||
.iter()
|
||||
.map(|n| n as &dyn rusqlite::types::ToSql)
|
||||
.collect();
|
||||
|
||||
let deleted = conn
|
||||
.execute(&sql, params.as_slice())
|
||||
.map_err(|e| format!("Failed to remove stale folders: {e}"))?;
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Touch `updated_at` on all folders so the folder-list cache TTL resets.
|
||||
pub fn touch_all_folders(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET updated_at = datetime('now')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to touch folders: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update only the uid_validity and total_count for a folder (without CASCADE side-effects).
|
||||
/// Also touches `messages_updated_at` to mark when this folder's messages were last synced.
|
||||
pub fn update_folder_status(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
uid_validity: u32,
|
||||
total_count: u32,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET uid_validity = ?1, total_count = ?2, messages_updated_at = datetime('now')
|
||||
WHERE name = ?3",
|
||||
params![uid_validity, total_count, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update folder status: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update server-side folder metadata after a CONDSTORE or STATUS check.
|
||||
/// Does not touch `messages_updated_at`: metadata alone does not mean the
|
||||
/// local message cache has been synchronized.
|
||||
pub fn update_folder_sync_status(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
uid_validity: u32,
|
||||
total_count: u32,
|
||||
highest_modseq: u64,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET uid_validity = ?1, total_count = ?2, highest_modseq = ?3
|
||||
WHERE name = ?4",
|
||||
params![uid_validity, total_count, highest_modseq as i64, name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update folder sync status: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recompute and store the unread count for a folder from the messages table.
|
||||
pub fn refresh_unread_count(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||
let unread: u32 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM messages WHERE folder = ?1 AND flags NOT LIKE '%\\Seen%'",
|
||||
params![folder_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to count unread messages: {e}"))?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE folders SET unread_count = ?1 WHERE name = ?2",
|
||||
params![unread, folder_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update unread_count: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adjust a folder's unread count by a signed delta (positive to increase, negative to decrease).
|
||||
/// Clamps to zero to avoid negative counts.
|
||||
pub fn adjust_unread_count(conn: &Connection, folder_name: &str, delta: i32) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET unread_count = MAX(0, CAST(unread_count AS INTEGER) + ?1) WHERE name = ?2",
|
||||
params![delta, folder_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to adjust unread_count: {e}"))?;
|
||||
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> {
|
||||
let fresh: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM folders
|
||||
WHERE updated_at > datetime('now', ?1)
|
||||
)",
|
||||
params![format!("-{max_age_secs} seconds")],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check folder cache freshness: {e}"))?;
|
||||
Ok(fresh)
|
||||
}
|
||||
|
||||
/// Check whether a specific folder's messages were synced within the last `max_age_secs` seconds.
|
||||
/// Uses `messages_updated_at` which is set by `update_folder_status` (message sync),
|
||||
/// independent of `updated_at` which tracks folder-list sync.
|
||||
pub fn is_folder_fresh(conn: &Connection, folder_name: &str, max_age_secs: u32) -> Result<bool, String> {
|
||||
let fresh: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM folders
|
||||
WHERE name = ?1 AND messages_updated_at > datetime('now', ?2)
|
||||
)",
|
||||
params![folder_name, format!("-{max_age_secs} seconds")],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check folder freshness: {e}"))?;
|
||||
Ok(fresh)
|
||||
}
|
||||
|
||||
/// Check whether a folder's messages cache has been invalidated
|
||||
/// (i.e., `messages_updated_at IS NULL`), meaning its unread count was
|
||||
/// manually adjusted and should not be recomputed from the messages table.
|
||||
pub fn is_folder_messages_invalidated(conn: &Connection, folder_name: &str) -> Result<bool, String> {
|
||||
let invalidated: bool = conn
|
||||
.query_row(
|
||||
"SELECT messages_updated_at IS NULL FROM folders WHERE name = ?1",
|
||||
params![folder_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
Ok(invalidated)
|
||||
}
|
||||
|
||||
/// Clear a folder's `messages_updated_at` so the next `is_folder_fresh` check
|
||||
/// returns `false` and forces an IMAP resync.
|
||||
pub fn invalidate_folder_freshness(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET messages_updated_at = NULL WHERE name = ?1",
|
||||
params![folder_name],
|
||||
)
|
||||
.map_err(|e| format!("Failed to invalidate folder freshness: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return a single folder by name, or `None` if not found.
|
||||
pub fn get_folder(conn: &Connection, name: &str) -> Result<Option<CachedFolder>, String> {
|
||||
let result = conn.query_row(
|
||||
"SELECT name, delimiter, parent, flags, is_subscribed,
|
||||
total_count, unread_count, uid_validity, highest_modseq
|
||||
FROM folders
|
||||
WHERE name = ?1",
|
||||
params![name],
|
||||
|row| {
|
||||
let is_subscribed_int: i32 = row.get(4)?;
|
||||
let highest_modseq_int: i64 = row.get(8)?;
|
||||
Ok(CachedFolder {
|
||||
name: row.get(0)?,
|
||||
delimiter: row.get(1)?,
|
||||
parent: row.get(2)?,
|
||||
flags: row.get(3)?,
|
||||
is_subscribed: is_subscribed_int != 0,
|
||||
total_count: row.get(5)?,
|
||||
unread_count: row.get(6)?,
|
||||
uid_validity: row.get(7)?,
|
||||
highest_modseq: highest_modseq_int as u64,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(folder) => Ok(Some(folder)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get folder: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a folder and all its cached messages.
|
||||
pub fn delete_folder_and_messages(conn: &Connection, folder_name: &str) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM messages WHERE folder = ?1", params![folder_name])
|
||||
.map_err(|e| format!("Failed to delete folder messages: {e}"))?;
|
||||
conn.execute("DELETE FROM folders WHERE name = ?1", params![folder_name])
|
||||
.map_err(|e| format!("Failed to delete folder: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a folder in the cache, updating both folders and messages tables.
|
||||
pub fn rename_folder_in_cache(conn: &Connection, old_name: &str, new_name: &str) -> Result<(), String> {
|
||||
let tx = conn.unchecked_transaction().map_err(|e| format!("Failed to start folder rename transaction: {e}"))?;
|
||||
tx.execute_batch("PRAGMA defer_foreign_keys = ON;")
|
||||
.map_err(|e| format!("Failed to defer folder rename foreign keys: {e}"))?;
|
||||
|
||||
let inserted = tx.execute(
|
||||
"INSERT INTO folders (name, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, updated_at, messages_updated_at)
|
||||
SELECT ?1, delimiter, parent, flags, is_subscribed, total_count, unread_count, uid_validity, highest_modseq, datetime('now'), messages_updated_at
|
||||
FROM folders WHERE name = ?2",
|
||||
params![new_name, old_name],
|
||||
).map_err(|e| format!("Failed to create renamed folder cache entry: {e}"))?;
|
||||
if inserted == 0 {
|
||||
return Err(format!("Folder not found in cache: {old_name}"));
|
||||
}
|
||||
|
||||
tx.execute("UPDATE messages SET folder = ?1 WHERE folder = ?2", params![new_name, old_name])
|
||||
.map_err(|e| format!("Failed to rename messages folder: {e}"))?;
|
||||
tx.execute("UPDATE message_tags SET message_folder = ?1 WHERE message_folder = ?2", params![new_name, old_name])
|
||||
.map_err(|e| format!("Failed to rename message tag folder references: {e}"))?;
|
||||
tx.execute("UPDATE calendar_events SET source_message_folder = ?1 WHERE source_message_folder = ?2", params![new_name, old_name])
|
||||
.ok();
|
||||
tx.execute("UPDATE folders SET parent = ?1 WHERE parent = ?2", params![new_name, old_name])
|
||||
.map_err(|e| format!("Failed to update child folder parent: {e}"))?;
|
||||
tx.execute("DELETE FROM folders WHERE name = ?1", params![old_name])
|
||||
.map_err(|e| format!("Failed to remove old folder cache entry: {e}"))?;
|
||||
tx.commit().map_err(|e| format!("Failed to commit folder rename: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a folder whose `flags` column contains the given attribute (e.g. `\Drafts`, `\Sent`).
|
||||
/// Returns the folder name if found.
|
||||
pub fn find_folder_by_attribute(conn: &Connection, attribute: &str) -> Result<Option<String>, String> {
|
||||
conn.query_row(
|
||||
"SELECT name FROM folders WHERE flags LIKE '%' || ?1 || '%' LIMIT 1",
|
||||
[attribute],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn test_upsert_and_get_folders() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 42, 5, 100, 200)
|
||||
.unwrap();
|
||||
upsert_folder(&conn, "Sent", Some("/"), None, "\\Sent", true, 10, 0, 101, 300)
|
||||
.unwrap();
|
||||
|
||||
let folders = get_all_folders(&conn).unwrap();
|
||||
assert_eq!(folders.len(), 2);
|
||||
|
||||
// Sorted alphabetically: INBOX < Sent
|
||||
assert_eq!(folders[0].name, "INBOX");
|
||||
assert_eq!(folders[0].total_count, 42);
|
||||
assert_eq!(folders[0].unread_count, 5);
|
||||
assert!(folders[0].is_subscribed);
|
||||
|
||||
assert_eq!(folders[1].name, "Sent");
|
||||
assert_eq!(folders[1].highest_modseq, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_updates_existing_folder() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 10, 2, 100, 50)
|
||||
.unwrap();
|
||||
|
||||
// Upsert again with different counts.
|
||||
upsert_folder(&conn, "INBOX", Some("/"), None, "\\HasNoChildren", true, 99, 33, 100, 75)
|
||||
.unwrap();
|
||||
|
||||
let folders = get_all_folders(&conn).unwrap();
|
||||
assert_eq!(folders.len(), 1);
|
||||
assert_eq!(folders[0].total_count, 99);
|
||||
assert_eq!(folders[0].unread_count, 33);
|
||||
assert_eq!(folders[0].highest_modseq, 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_metadata_does_not_revalidate_invalidated_messages() {
|
||||
let conn = open_test_db();
|
||||
insert_folder_if_new(&conn, "Sent", None, "\\Sent").unwrap();
|
||||
update_folder_status(&conn, "Sent", 1, 0).unwrap();
|
||||
assert!(is_folder_fresh(&conn, "Sent", 30).unwrap());
|
||||
|
||||
invalidate_folder_freshness(&conn, "Sent").unwrap();
|
||||
update_folder_sync_status(&conn, "Sent", 2, 1, 42).unwrap();
|
||||
|
||||
assert!(is_folder_messages_invalidated(&conn, "Sent").unwrap());
|
||||
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
|
||||
assert_eq!(crate::db::messages::count_messages(&conn, "Sent").unwrap(), 0);
|
||||
let folder = get_folder(&conn, "Sent").unwrap().unwrap();
|
||||
assert_eq!(folder.uid_validity, 2);
|
||||
assert_eq!(folder.total_count, 1);
|
||||
assert_eq!(folder.highest_modseq, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_metadata_preserves_message_sync_timestamp() {
|
||||
let conn = open_test_db();
|
||||
insert_folder_if_new(&conn, "Sent", None, "\\Sent").unwrap();
|
||||
conn.execute(
|
||||
"UPDATE folders SET messages_updated_at = '2000-01-01 00:00:00' WHERE name = 'Sent'",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
update_folder_sync_status(&conn, "Sent", 1, 5, 42).unwrap();
|
||||
|
||||
let timestamp: String = conn.query_row(
|
||||
"SELECT messages_updated_at FROM folders WHERE name = 'Sent'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(timestamp, "2000-01-01 00:00:00");
|
||||
assert!(!is_folder_fresh(&conn, "Sent", 30).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_stale_folders() {
|
||||
let conn = open_test_db();
|
||||
|
||||
upsert_folder(&conn, "INBOX", 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();
|
||||
|
||||
// Keep only INBOX and Sent; Trash should be removed.
|
||||
let current = vec!["INBOX".to_string(), "Sent".to_string()];
|
||||
let deleted = remove_stale_folders(&conn, ¤t).unwrap();
|
||||
assert_eq!(deleted, 1);
|
||||
|
||||
let folders = get_all_folders(&conn).unwrap();
|
||||
let names: Vec<&str> = folders.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"INBOX"));
|
||||
assert!(names.contains(&"Sent"));
|
||||
assert!(!names.contains(&"Trash"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Reject header batches whose IMAP reads overlap a completed cache removal.
|
||||
//!
|
||||
//! The generation is per user database, not per UID: a later sync may discover
|
||||
//! new destination UIDs or reuse UIDs after UIDVALIDITY changes. No tombstones
|
||||
//! or locks held over network calls are needed.
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct HeaderSync {
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl HeaderSync {
|
||||
/// Capture before reading IMAP state, and retain for the whole sync pass.
|
||||
pub(crate) fn capture(conn: &Connection) -> Result<Self, String> {
|
||||
let generation = conn
|
||||
.query_row(
|
||||
"SELECT value FROM user_meta WHERE key = 'header_sync_generation'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|value| value.parse::<u64>().map_err(|e| e.to_string()))
|
||||
.transpose()?
|
||||
.unwrap_or(0);
|
||||
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> {
|
||||
let transaction = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if Self::capture(conn)?.generation != self.generation {
|
||||
return Err("Header sync superseded by cache removal; retry on next sync".into());
|
||||
}
|
||||
Ok(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
/// Must run in the same write transaction as the removal or UIDVALIDITY reset.
|
||||
pub(crate) fn invalidate(conn: &Transaction<'_>) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT INTO user_meta (key, value) VALUES ('header_sync_generation', '1')
|
||||
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Identity {
|
||||
pub id: i64,
|
||||
pub display_name: String,
|
||||
pub email: String,
|
||||
pub signature_html: String,
|
||||
pub is_default: bool,
|
||||
pub reply_to: String,
|
||||
pub bcc: String,
|
||||
pub organization: String,
|
||||
pub signature_text: String,
|
||||
pub signature_new: bool,
|
||||
pub signature_reply: bool,
|
||||
pub signature_forward: bool,
|
||||
pub signature_position: String,
|
||||
pub pgp_fingerprint: Option<String>,
|
||||
pub pgp_sign_default: bool,
|
||||
pub pgp_encrypt_default: bool,
|
||||
pub pgp_inline_default: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateIdentity {
|
||||
#[serde(default)] pub display_name: String,
|
||||
pub email: String,
|
||||
#[serde(default)] pub signature_html: String,
|
||||
#[serde(default)] pub is_default: bool,
|
||||
#[serde(default)] pub reply_to: String,
|
||||
#[serde(default)] pub bcc: String,
|
||||
#[serde(default)] pub organization: String,
|
||||
#[serde(default)] pub signature_text: String,
|
||||
#[serde(default = "default_true")] pub signature_new: bool,
|
||||
#[serde(default = "default_true")] pub signature_reply: bool,
|
||||
#[serde(default = "default_true")] pub signature_forward: bool,
|
||||
#[serde(default = "default_signature_position")] pub signature_position: String,
|
||||
#[serde(default)] pub pgp_fingerprint: Option<String>,
|
||||
#[serde(default)] pub pgp_sign_default: bool,
|
||||
#[serde(default)] pub pgp_encrypt_default: bool,
|
||||
#[serde(default)] pub pgp_inline_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateIdentity {
|
||||
pub display_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub signature_html: Option<String>,
|
||||
pub is_default: Option<bool>,
|
||||
pub reply_to: Option<String>,
|
||||
pub bcc: Option<String>,
|
||||
pub organization: Option<String>,
|
||||
pub signature_text: Option<String>,
|
||||
pub signature_new: Option<bool>,
|
||||
pub signature_reply: Option<bool>,
|
||||
pub signature_forward: Option<bool>,
|
||||
pub signature_position: Option<String>,
|
||||
pub pgp_fingerprint: Option<Option<String>>,
|
||||
pub pgp_sign_default: Option<bool>,
|
||||
pub pgp_encrypt_default: Option<bool>,
|
||||
pub pgp_inline_default: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
fn default_signature_position() -> String { "above".to_string() }
|
||||
|
||||
fn validate_signature_position(value: &str) -> Result<(), String> {
|
||||
if matches!(value, "above" | "below") { Ok(()) } else { Err(format!("Invalid signature_position: {value}")) }
|
||||
}
|
||||
|
||||
fn row_to_identity(row: &rusqlite::Row<'_>) -> rusqlite::Result<Identity> {
|
||||
Ok(Identity {
|
||||
id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
email: row.get(2)?,
|
||||
signature_html: row.get(3)?,
|
||||
is_default: row.get::<_, i32>(4)? != 0,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
reply_to: row.get(7)?,
|
||||
bcc: row.get(8)?,
|
||||
organization: row.get(9)?,
|
||||
signature_text: row.get(10)?,
|
||||
signature_new: row.get::<_, i32>(11)? != 0,
|
||||
signature_reply: row.get::<_, i32>(12)? != 0,
|
||||
signature_forward: row.get::<_, i32>(13)? != 0,
|
||||
signature_position: row.get(14)?,
|
||||
pgp_fingerprint: row.get(15)?,
|
||||
pgp_sign_default: row.get::<_, i32>(16)? != 0,
|
||||
pgp_encrypt_default: row.get::<_, i32>(17)? != 0,
|
||||
pgp_inline_default: row.get::<_, i32>(18)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
|
||||
|
||||
pub fn list_identities(conn: &Connection) -> Result<Vec<Identity>, String> {
|
||||
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities ORDER BY is_default DESC, email ASC");
|
||||
let mut stmt = conn.prepare(&sql).map_err(|e| format!("Failed to prepare list_identities: {e}"))?;
|
||||
let rows = stmt.query_map([], row_to_identity).map_err(|e| format!("Failed to query identities: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("Failed to read identity row: {e}"))).collect()
|
||||
}
|
||||
|
||||
pub fn get_identity(conn: &Connection, id: i64) -> Result<Option<Identity>, String> {
|
||||
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE id = ?1");
|
||||
match conn.query_row(&sql, params![id], row_to_identity) {
|
||||
Ok(identity) => Ok(Some(identity)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get identity: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_default_identity(conn: &Connection) -> Result<Option<Identity>, String> {
|
||||
let sql = format!("SELECT {IDENTITY_SELECT_COLS} FROM identities WHERE is_default = 1 LIMIT 1");
|
||||
match conn.query_row(&sql, [], row_to_identity) {
|
||||
Ok(identity) => Ok(Some(identity)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get default identity: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result<Identity, String> {
|
||||
validate_signature_position(&data.signature_position)?;
|
||||
if data.is_default { clear_default(conn)?; }
|
||||
conn.execute(
|
||||
"INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)",
|
||||
params![data.display_name, data.email, data.signature_html, data.is_default as i32, data.reply_to, data.bcc, data.organization, data.signature_text, data.signature_new as i32, data.signature_reply as i32, data.signature_forward as i32, data.signature_position, data.pgp_fingerprint, data.pgp_sign_default as i32, data.pgp_encrypt_default as i32, data.pgp_inline_default as i32],
|
||||
).map_err(|e| format!("Failed to create identity: {e}"))?;
|
||||
let id = conn.last_insert_rowid();
|
||||
get_identity(conn, id)?.ok_or_else(|| "Failed to read back created identity".to_string())
|
||||
}
|
||||
|
||||
pub fn update_identity(conn: &Connection, id: i64, data: &UpdateIdentity) -> Result<Option<Identity>, String> {
|
||||
if get_identity(conn, id)?.is_none() { return Ok(None); }
|
||||
if data.is_default == Some(true) { clear_default(conn)?; }
|
||||
if let Some(value) = data.signature_position.as_deref() { validate_signature_position(value)?; }
|
||||
let mut sets = Vec::new();
|
||||
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let mut idx = 1usize;
|
||||
macro_rules! text { ($field:ident,$column:literal) => { if let Some(ref value)=data.$field { sets.push(format!(concat!($column," = ?{}"),idx)); values.push(Box::new(value.clone())); idx += 1; } }; }
|
||||
macro_rules! boolean { ($field:ident,$column:literal) => { if let Some(value)=data.$field { sets.push(format!(concat!($column," = ?{}"),idx)); values.push(Box::new(value as i32)); idx += 1; } }; }
|
||||
text!(display_name,"display_name"); text!(email,"email"); text!(signature_html,"signature_html"); boolean!(is_default,"is_default");
|
||||
text!(reply_to,"reply_to"); text!(bcc,"bcc"); text!(organization,"organization"); text!(signature_text,"signature_text");
|
||||
boolean!(signature_new,"signature_new"); boolean!(signature_reply,"signature_reply"); boolean!(signature_forward,"signature_forward"); text!(signature_position,"signature_position");
|
||||
if let Some(ref value) = data.pgp_fingerprint { sets.push(format!("pgp_fingerprint = ?{}", idx)); values.push(Box::new(value.clone())); idx += 1; }
|
||||
boolean!(pgp_sign_default,"pgp_sign_default"); boolean!(pgp_encrypt_default,"pgp_encrypt_default"); boolean!(pgp_inline_default,"pgp_inline_default");
|
||||
if sets.is_empty() { return get_identity(conn,id); }
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
let sql = format!("UPDATE identities SET {} WHERE id = ?{}", sets.join(", "), idx);
|
||||
values.push(Box::new(id));
|
||||
let refs: Vec<&dyn rusqlite::types::ToSql> = values.iter().map(|value| value.as_ref()).collect();
|
||||
conn.execute(&sql, refs.as_slice()).map_err(|e| format!("Failed to update identity: {e}"))?;
|
||||
get_identity(conn,id)
|
||||
}
|
||||
|
||||
pub fn delete_identity(conn: &Connection, id: i64) -> Result<bool, String> {
|
||||
Ok(conn.execute("DELETE FROM identities WHERE id = ?1", params![id]).map_err(|e| format!("Failed to delete identity: {e}"))? > 0)
|
||||
}
|
||||
|
||||
pub fn has_identities(conn: &Connection) -> Result<bool, String> {
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM identities", [], |row| row.get(0)).map_err(|e| format!("Failed to count identities: {e}"))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
fn clear_default(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute("UPDATE identities SET is_default = 0, updated_at = datetime('now') WHERE is_default = 1", []).map_err(|e| format!("Failed to clear default identity: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
fn sample(email: &str, is_default: bool) -> CreateIdentity {
|
||||
CreateIdentity { display_name:"Alice".into(), email:email.into(), signature_html:"<p>Best</p>".into(), is_default, reply_to:String::new(), bcc:String::new(), organization:String::new(), signature_text:"Best".into(), signature_new:true, signature_reply:true, signature_forward:true, signature_position:"above".into(), pgp_fingerprint:None, pgp_sign_default:false, pgp_encrypt_default:false, pgp_inline_default:false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_extended_options_roundtrip() {
|
||||
let conn=open_test_db();
|
||||
let mut value=sample("alice@example.com", true);
|
||||
value.reply_to="reply@example.com".into(); value.bcc="archive@example.com".into(); value.organization="Example".into(); value.signature_position="below".into();
|
||||
let identity=create_identity(&conn,&value).unwrap();
|
||||
assert_eq!(identity.reply_to,"reply@example.com"); assert_eq!(identity.bcc,"archive@example.com"); assert_eq!(identity.organization,"Example"); assert_eq!(identity.signature_position,"below");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_default() {
|
||||
let conn=open_test_db();
|
||||
let first=create_identity(&conn,&sample("a@example.com",true)).unwrap();
|
||||
let second=create_identity(&conn,&sample("b@example.com",true)).unwrap();
|
||||
assert!(second.is_default); assert!(!get_identity(&conn,first.id).unwrap().unwrap().is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_signature_position() {
|
||||
let conn=open_test_db(); let mut value=sample("a@example.com",false); value.signature_position="side".into(); assert!(create_identity(&conn,&value).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MailPreferences {
|
||||
pub messages_per_page: u32,
|
||||
pub mark_read_delay_ms: u64,
|
||||
pub remote_images: String,
|
||||
pub preferred_body: String,
|
||||
pub thread_mode: String,
|
||||
pub collapse_quotes: bool,
|
||||
pub show_snippets: bool,
|
||||
pub default_sort: String,
|
||||
pub flagged_on_top: bool,
|
||||
pub sync_imap_keywords: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateMailPreferences {
|
||||
pub messages_per_page: Option<u32>,
|
||||
pub mark_read_delay_ms: Option<u64>,
|
||||
pub remote_images: Option<String>,
|
||||
pub preferred_body: Option<String>,
|
||||
pub thread_mode: Option<String>,
|
||||
pub collapse_quotes: Option<bool>,
|
||||
pub show_snippets: Option<bool>,
|
||||
pub default_sort: Option<String>,
|
||||
pub flagged_on_top: Option<bool>,
|
||||
pub sync_imap_keywords: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn get_preferences(conn: &Connection) -> Result<MailPreferences, String> {
|
||||
conn.query_row(
|
||||
"SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(MailPreferences {
|
||||
messages_per_page: row.get::<_, u32>(0)?,
|
||||
mark_read_delay_ms: row.get::<_, u64>(1)?,
|
||||
remote_images: row.get(2)?,
|
||||
preferred_body: row.get(3)?,
|
||||
thread_mode: row.get(4)?,
|
||||
collapse_quotes: row.get::<_, i32>(5)? != 0,
|
||||
show_snippets: row.get::<_, i32>(6)? != 0,
|
||||
default_sort: row.get(7)?,
|
||||
flagged_on_top: row.get::<_, i32>(8)? != 0,
|
||||
sync_imap_keywords: row.get::<_, i32>(9)? != 0,
|
||||
updated_at: row.get(10)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|error| format!("Failed to read mail preferences: {error}"))
|
||||
}
|
||||
|
||||
pub fn update_preferences(
|
||||
conn: &Connection,
|
||||
data: &UpdateMailPreferences,
|
||||
) -> Result<MailPreferences, String> {
|
||||
conn.execute("INSERT OR IGNORE INTO mail_preferences (id) VALUES (1)", [])
|
||||
.map_err(|error| format!("Failed to initialize mail preferences: {error}"))?;
|
||||
|
||||
if let Some(value) = data.messages_per_page {
|
||||
if !(20..=200).contains(&value) {
|
||||
return Err("messages_per_page must be between 20 and 200".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = data.mark_read_delay_ms {
|
||||
if value > 30_000 {
|
||||
return Err("mark_read_delay_ms must be between 0 and 30000".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(ref value) = data.remote_images {
|
||||
if !matches!(value.as_str(), "never" | "contacts" | "ask" | "always") {
|
||||
return Err("remote_images must be never, contacts, ask or always".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(ref value) = data.preferred_body {
|
||||
if !matches!(value.as_str(), "auto" | "html" | "text") {
|
||||
return Err("preferred_body must be auto, html or text".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(ref value) = data.thread_mode {
|
||||
if !matches!(value.as_str(), "threaded" | "flat") {
|
||||
return Err("thread_mode must be threaded or flat".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(ref value) = data.default_sort {
|
||||
if !matches!(
|
||||
value.as_str(),
|
||||
"date_desc" | "date_asc" | "sender_asc" | "sender_desc" | "subject_asc" | "subject_desc" | "size_asc" | "size_desc"
|
||||
) {
|
||||
return Err("unsupported default_sort".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let current = get_preferences(conn)?;
|
||||
let messages_per_page = data.messages_per_page.unwrap_or(current.messages_per_page);
|
||||
let mark_read_delay_ms = data.mark_read_delay_ms.unwrap_or(current.mark_read_delay_ms);
|
||||
let remote_images = data.remote_images.as_deref().unwrap_or(¤t.remote_images);
|
||||
let preferred_body = data.preferred_body.as_deref().unwrap_or(¤t.preferred_body);
|
||||
let thread_mode = data.thread_mode.as_deref().unwrap_or(¤t.thread_mode);
|
||||
let collapse_quotes = data.collapse_quotes.unwrap_or(current.collapse_quotes);
|
||||
let show_snippets = data.show_snippets.unwrap_or(current.show_snippets);
|
||||
let default_sort = data.default_sort.as_deref().unwrap_or(¤t.default_sort);
|
||||
let flagged_on_top = data.flagged_on_top.unwrap_or(current.flagged_on_top);
|
||||
let sync_imap_keywords = data.sync_imap_keywords.unwrap_or(current.sync_imap_keywords);
|
||||
|
||||
conn.execute(
|
||||
"UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, updated_at = datetime('now') WHERE id = 1",
|
||||
rusqlite::params![messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes as i32, show_snippets as i32, default_sort, flagged_on_top as i32, sync_imap_keywords as i32],
|
||||
)
|
||||
.map_err(|error| format!("Failed to update mail preferences: {error}"))?;
|
||||
|
||||
get_preferences(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn defaults_and_update_work() {
|
||||
let conn = open_test_db();
|
||||
let defaults = get_preferences(&conn).unwrap();
|
||||
assert_eq!(defaults.messages_per_page, 50);
|
||||
assert_eq!(defaults.remote_images, "ask");
|
||||
|
||||
let updated = update_preferences(
|
||||
&conn,
|
||||
&UpdateMailPreferences {
|
||||
messages_per_page: Some(100),
|
||||
mark_read_delay_ms: Some(1500),
|
||||
remote_images: Some("never".to_string()),
|
||||
preferred_body: Some("text".to_string()),
|
||||
thread_mode: Some("flat".to_string()),
|
||||
collapse_quotes: Some(false),
|
||||
show_snippets: Some(false),
|
||||
default_sort: Some("date_asc".to_string()),
|
||||
flagged_on_top: Some(true),
|
||||
sync_imap_keywords: Some(true),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.messages_per_page, 100);
|
||||
assert_eq!(updated.mark_read_delay_ms, 1500);
|
||||
assert_eq!(updated.thread_mode, "flat");
|
||||
assert!(updated.flagged_on_top);
|
||||
assert!(updated.sync_imap_keywords);
|
||||
}
|
||||
}
|
||||
+1180
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
||||
pub mod calendar;
|
||||
pub mod contact_groups;
|
||||
pub mod contacts;
|
||||
pub mod display_preferences;
|
||||
pub mod drafts;
|
||||
pub mod folders;
|
||||
pub mod folder_preferences;
|
||||
pub(crate) mod header_sync;
|
||||
pub mod identities;
|
||||
pub mod mail_preferences;
|
||||
pub mod messages;
|
||||
pub mod notification_preferences;
|
||||
pub mod pgp;
|
||||
pub mod pool;
|
||||
pub mod special_folders;
|
||||
pub mod tags;
|
||||
|
||||
pub mod saved_searches;
|
||||
@@ -0,0 +1,227 @@
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NotificationPreferences {
|
||||
pub enabled: bool,
|
||||
pub sound: bool,
|
||||
pub folders: Vec<String>,
|
||||
pub dnd_enabled: bool,
|
||||
pub dnd_start: String,
|
||||
pub dnd_end: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateNotificationPreferences {
|
||||
pub enabled: Option<bool>,
|
||||
pub sound: Option<bool>,
|
||||
pub folders: Option<Vec<String>>,
|
||||
pub dnd_enabled: Option<bool>,
|
||||
pub dnd_start: 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> {
|
||||
let result = conn.query_row(
|
||||
"SELECT enabled, sound, folders, dnd_enabled, dnd_start, dnd_end, updated_at FROM notification_preferences WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let enabled_int: i32 = row.get(0)?;
|
||||
let sound_int: i32 = row.get(1)?;
|
||||
let folders_json: String = row.get(2)?;
|
||||
let dnd_enabled_int: i32 = row.get(3)?;
|
||||
let dnd_start: String = row.get(4)?;
|
||||
let dnd_end: String = row.get(5)?;
|
||||
let updated_at: String = row.get(6)?;
|
||||
Ok((enabled_int, sound_int, folders_json, dnd_enabled_int, dnd_start, dnd_end, updated_at))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
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)
|
||||
.map_err(|e| format!("Failed to parse folders JSON: {e}"))?;
|
||||
Ok(NotificationPreferences {
|
||||
enabled: enabled_int != 0,
|
||||
sound: sound_int != 0,
|
||||
folders,
|
||||
dnd_enabled: dnd_enabled_int != 0,
|
||||
dnd_start,
|
||||
dnd_end,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(NotificationPreferences {
|
||||
enabled: true,
|
||||
sound: false,
|
||||
folders: vec!["INBOX".to_string()],
|
||||
dnd_enabled: false,
|
||||
dnd_start: "22:00".to_string(),
|
||||
dnd_end: "07:00".to_string(),
|
||||
updated_at: String::new(),
|
||||
}),
|
||||
Err(e) => Err(format!("Failed to get notification preferences: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the singleton notification preferences row.
|
||||
/// Only provided fields are changed. Returns the updated preferences.
|
||||
pub fn update_preferences(
|
||||
conn: &Connection,
|
||||
data: &UpdateNotificationPreferences,
|
||||
) -> Result<NotificationPreferences, String> {
|
||||
// Ensure the row exists.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO notification_preferences (id) VALUES (1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to ensure preferences row: {e}"))?;
|
||||
|
||||
let mut sets = Vec::new();
|
||||
let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
let mut idx = 1;
|
||||
|
||||
if let Some(enabled) = data.enabled {
|
||||
sets.push(format!("enabled = ?{idx}"));
|
||||
values.push(Box::new(enabled as i32));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(sound) = data.sound {
|
||||
sets.push(format!("sound = ?{idx}"));
|
||||
values.push(Box::new(sound as i32));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(ref folders) = data.folders {
|
||||
let folders_json =
|
||||
serde_json::to_string(folders).map_err(|e| format!("Failed to serialize folders: {e}"))?;
|
||||
sets.push(format!("folders = ?{idx}"));
|
||||
values.push(Box::new(folders_json));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(dnd_enabled) = data.dnd_enabled {
|
||||
sets.push(format!("dnd_enabled = ?{idx}"));
|
||||
values.push(Box::new(dnd_enabled as i32));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(ref dnd_start) = data.dnd_start {
|
||||
if !valid_clock(dnd_start) { return Err("Invalid DND start time".to_string()); }
|
||||
sets.push(format!("dnd_start = ?{idx}"));
|
||||
values.push(Box::new(dnd_start.clone()));
|
||||
idx += 1;
|
||||
}
|
||||
if let Some(ref dnd_end) = data.dnd_end {
|
||||
if !valid_clock(dnd_end) { return Err("Invalid DND end time".to_string()); }
|
||||
sets.push(format!("dnd_end = ?{idx}"));
|
||||
values.push(Box::new(dnd_end.clone()));
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
if sets.is_empty() {
|
||||
return get_preferences(conn);
|
||||
}
|
||||
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
let set_clause = sets.join(", ");
|
||||
let sql = format!("UPDATE notification_preferences SET {set_clause} WHERE id = ?{idx}");
|
||||
values.push(Box::new(1_i32));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
values.iter().map(|v| v.as_ref()).collect();
|
||||
|
||||
conn.execute(&sql, params_refs.as_slice())
|
||||
.map_err(|e| format!("Failed to update notification preferences: {e}"))?;
|
||||
|
||||
get_preferences(conn)
|
||||
}
|
||||
|
||||
fn valid_clock(value: &str) -> bool {
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::pool::open_test_db;
|
||||
|
||||
#[test]
|
||||
fn test_get_default_preferences() {
|
||||
let conn = open_test_db();
|
||||
let prefs = get_preferences(&conn).unwrap();
|
||||
|
||||
assert!(prefs.enabled);
|
||||
assert!(!prefs.sound);
|
||||
assert_eq!(prefs.folders, vec!["INBOX".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_enabled() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let prefs = update_preferences(
|
||||
&conn,
|
||||
&UpdateNotificationPreferences {
|
||||
enabled: Some(false),
|
||||
sound: None,
|
||||
folders: None,
|
||||
dnd_enabled: None,
|
||||
dnd_start: None,
|
||||
dnd_end: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!prefs.enabled);
|
||||
assert!(!prefs.sound);
|
||||
assert_eq!(prefs.folders, vec!["INBOX".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_folders() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let prefs = update_preferences(
|
||||
&conn,
|
||||
&UpdateNotificationPreferences {
|
||||
enabled: None,
|
||||
sound: None,
|
||||
folders: Some(vec!["INBOX".to_string(), "Sent".to_string()]),
|
||||
dnd_enabled: None,
|
||||
dnd_start: None,
|
||||
dnd_end: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(prefs.enabled);
|
||||
assert_eq!(
|
||||
prefs.folders,
|
||||
vec!["INBOX".to_string(), "Sent".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_all_fields() {
|
||||
let conn = open_test_db();
|
||||
|
||||
let prefs = update_preferences(
|
||||
&conn,
|
||||
&UpdateNotificationPreferences {
|
||||
enabled: Some(false),
|
||||
sound: Some(true),
|
||||
folders: Some(vec!["Drafts".to_string()]),
|
||||
dnd_enabled: None,
|
||||
dnd_start: None,
|
||||
dnd_end: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!prefs.enabled);
|
||||
assert!(prefs.sound);
|
||||
assert_eq!(prefs.folders, vec!["Drafts".to_string()]);
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PgpKeyRecord {
|
||||
pub fingerprint: String,
|
||||
pub key_id: String,
|
||||
pub email: String,
|
||||
pub user_id: String,
|
||||
pub public_key_armor: String,
|
||||
pub has_secret: bool,
|
||||
pub trust: String,
|
||||
pub source: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PgpKeyInternal {
|
||||
pub record: PgpKeyRecord,
|
||||
pub secret_key_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PgpPreferences {
|
||||
pub wkd_enabled: bool,
|
||||
pub hkp_enabled: bool,
|
||||
pub require_trusted_encryption: bool,
|
||||
}
|
||||
|
||||
fn row_to_key(row: &rusqlite::Row<'_>) -> rusqlite::Result<PgpKeyInternal> {
|
||||
Ok(PgpKeyInternal {
|
||||
record: PgpKeyRecord {
|
||||
fingerprint: row.get(0)?,
|
||||
key_id: row.get(1)?,
|
||||
email: row.get(2)?,
|
||||
user_id: row.get(3)?,
|
||||
public_key_armor: row.get(4)?,
|
||||
has_secret: row.get::<_, i64>(6)? != 0,
|
||||
trust: row.get(7)?,
|
||||
source: row.get(8)?,
|
||||
created_at: row.get(9)?,
|
||||
updated_at: row.get(10)?,
|
||||
},
|
||||
secret_key_path: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
const KEY_COLS: &str = "fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source,created_at,updated_at";
|
||||
|
||||
pub fn list_keys(conn: &Connection) -> Result<Vec<PgpKeyRecord>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("SELECT {KEY_COLS} FROM pgp_keys ORDER BY has_secret DESC, lower(email), fingerprint"))
|
||||
.map_err(|e| format!("Failed to prepare PGP key list: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], row_to_key)
|
||||
.map_err(|e| format!("Failed to list PGP keys: {e}"))?;
|
||||
rows.map(|row| row.map(|v| v.record).map_err(|e| format!("Failed to read PGP key: {e}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_key(conn: &Connection, fingerprint: &str) -> Result<Option<PgpKeyInternal>, String> {
|
||||
let normalized = normalize_fingerprint(fingerprint);
|
||||
let sql = format!("SELECT {KEY_COLS} FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1");
|
||||
match conn.query_row(&sql, params![normalized], row_to_key) {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get PGP key: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_keys_by_email(conn: &Connection, email: &str) -> Result<Vec<PgpKeyRecord>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC"))
|
||||
.map_err(|e| format!("Failed to prepare PGP email lookup: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map(params![email.trim()], row_to_key)
|
||||
.map_err(|e| format!("Failed to query PGP keys by email: {e}"))?;
|
||||
rows.map(|row| row.map(|v| v.record).map_err(|e| format!("Failed to read PGP key: {e}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn upsert_key(
|
||||
conn: &Connection,
|
||||
record: &PgpKeyRecord,
|
||||
secret_key_path: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT INTO pgp_keys (fingerprint,key_id,email,user_id,public_key_armor,secret_key_path,has_secret,trust,source) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)\n ON CONFLICT(fingerprint) DO UPDATE SET key_id=excluded.key_id,email=CASE WHEN excluded.email='' THEN pgp_keys.email ELSE excluded.email END,user_id=CASE WHEN excluded.user_id='' THEN pgp_keys.user_id ELSE excluded.user_id END,public_key_armor=excluded.public_key_armor,secret_key_path=COALESCE(excluded.secret_key_path,pgp_keys.secret_key_path),has_secret=MAX(pgp_keys.has_secret,excluded.has_secret),trust=excluded.trust,source=excluded.source,updated_at=datetime('now')",
|
||||
params![
|
||||
record.fingerprint,
|
||||
record.key_id,
|
||||
record.email,
|
||||
record.user_id,
|
||||
record.public_key_armor,
|
||||
secret_key_path,
|
||||
record.has_secret as i32,
|
||||
record.trust,
|
||||
record.source
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to store PGP key: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_key(conn: &Connection, fingerprint: &str) -> Result<bool, String> {
|
||||
Ok(conn
|
||||
.execute(
|
||||
"DELETE FROM pgp_keys WHERE replace(upper(fingerprint),' ','')=?1",
|
||||
params![normalize_fingerprint(fingerprint)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to delete PGP key: {e}"))?
|
||||
> 0)
|
||||
}
|
||||
|
||||
pub fn set_trust(conn: &Connection, fingerprint: &str, trust: &str) -> Result<bool, String> {
|
||||
if !matches!(trust, "unknown" | "untrusted" | "trusted" | "verified") {
|
||||
return Err("Trust must be unknown, untrusted, trusted, or verified".to_string());
|
||||
}
|
||||
Ok(conn
|
||||
.execute(
|
||||
"UPDATE pgp_keys SET trust=?2,updated_at=datetime('now') WHERE replace(upper(fingerprint),' ','')=?1",
|
||||
params![normalize_fingerprint(fingerprint), trust],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update PGP trust: {e}"))?
|
||||
> 0)
|
||||
}
|
||||
|
||||
pub fn get_preferences(conn: &Connection) -> Result<PgpPreferences, String> {
|
||||
conn.query_row(
|
||||
"SELECT wkd_enabled,hkp_enabled,require_trusted_encryption FROM pgp_preferences WHERE id=1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(PgpPreferences {
|
||||
wkd_enabled: row.get::<_, i32>(0)? != 0,
|
||||
hkp_enabled: row.get::<_, i32>(1)? != 0,
|
||||
require_trusted_encryption: row.get::<_, i32>(2)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to read PGP preferences: {e}"))
|
||||
}
|
||||
|
||||
pub fn update_preferences(conn: &Connection, prefs: &PgpPreferences) -> Result<PgpPreferences, String> {
|
||||
conn.execute(
|
||||
"UPDATE pgp_preferences SET wkd_enabled=?1,hkp_enabled=?2,require_trusted_encryption=?3,updated_at=datetime('now') WHERE id=1",
|
||||
params![prefs.wkd_enabled as i32, prefs.hkp_enabled as i32, prefs.require_trusted_encryption as i32],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update PGP preferences: {e}"))?;
|
||||
get_preferences(conn)
|
||||
}
|
||||
|
||||
pub fn normalize_fingerprint(value: &str) -> String {
|
||||
value.chars().filter(|c| !c.is_ascii_whitespace()).flat_map(char::to_uppercase).collect()
|
||||
}
|
||||
|
||||
pub fn find_key_internal_by_email(conn: &Connection, email: &str) -> Result<Option<PgpKeyInternal>, String> {
|
||||
let sql = format!("SELECT {KEY_COLS} FROM pgp_keys WHERE lower(email)=lower(?1) ORDER BY CASE trust WHEN 'verified' THEN 0 WHEN 'trusted' THEN 1 WHEN 'unknown' THEN 2 ELSE 3 END, has_secret DESC LIMIT 1");
|
||||
match conn.query_row(&sql, params![email.trim()], row_to_key) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to query PGP key by email: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_secret_keys(conn: &Connection) -> Result<Vec<PgpKeyInternal>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("SELECT {KEY_COLS} FROM pgp_keys WHERE has_secret=1 AND secret_key_path IS NOT NULL ORDER BY lower(email), fingerprint"))
|
||||
.map_err(|e| format!("Failed to prepare PGP secret key list: {e}"))?;
|
||||
let rows = stmt
|
||||
.query_map([], row_to_key)
|
||||
.map_err(|e| format!("Failed to list PGP secret keys: {e}"))?;
|
||||
rows.map(|row| row.map_err(|e| format!("Failed to read PGP secret key: {e}"))).collect()
|
||||
}
|
||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user